객체지향 프로그래밍과 함수형 프로그래밍

부스트캠프 챌린지 과정 중에서 프로그래밍 패러다임을 공부한 적이 있습니다. 그때 함수형 프로그래밍을 처음 접했는데 호되게 당했던 기억이 있네요. 프로그래밍 패러다임에 대한 내용을 새로 정리해보았습니다.

순차적(비구조적) 프로그래밍

순차적 프로그래밍정의한 기능의 흐름에 따라 순서대로 동작을 추가하며 프로그램을 완성하는 방식입니다. 간단한 프로그램의 경우 이렇게 코드를 짜게 되면, 코드의 흐름이 눈으로 보이기 때문에 매우 직관적입니다. 하지만 조금이라도 프로그램의 구조가 커지게 된다면 곤란해집니다. 만약 A->B->C 라는 동작을 구현하다가 C에서 A로 돌아가야할 상황이라면 goto를 활용해야 합니다. 이 goto문을 무분별하게 활용하게 되면 일명 스파게티 코드가 완성됩니다. 쭉 나열된 코드 속에서 어디로 튈지 모르게 되므로 동작이 직관적이지 못하게 되는데, 순차적 프로그래밍의 유일한 장점이 사라지게 됩니다. 따라서 절차적 프로그래밍 패러다임이 등장하게 됩니다.

절차적(구조적) 프로그래밍

절차적 프로그래밍에서 ‘절차’는 ‘함수’를 의미합니다. 따라서 절차적 프로그래밍이란 반복되는 동작을 함수 및 프로시저 형태로 모듈화하여 사용하는 방식입니다.

  • 프로시저: 리턴값이 없는 함수이다. 데이터를 출력하는 용도로 사용하는 print() 함수를 프로시저라고 한다. 절차적 프로그래밍을 통해 반복 동작을 모듈화하여 코드의 용량을 많이 줄일 수 있습니다. 하지만 프로시저라는 것이 너무 추상적이라는 단점이 있습니다.
    도서관의 도서 관리 프로그램을 개발한다고 가정했을 때, “‘책’이라는 자료형을 구현하기”와 “책에 대한 함수를 구현하기”를 따로 생각해야 합니다. 책은 책이고, 책에 관한 함수는 따로 있기 때문입니다. 같은 소스코드 파일 내에 있더라도 이 둘의 연관 여부는 단번에 알아차리기 힘듭니다. 논리적으로 묶여있을 수 없는 구조이기 때문에 동작이 추상적인 것입니다. 이를 묶기 위해 객체지향 프로그래밍 패러다임이 등장합니다.

객체지향 프로그래밍

객체지향 프로그래밍은 어떤 개념에 대한 자료형과 함수를 객체 형태로 함께 묶어서 관리하기 위해 등장한 패러다임입니다. 핵심 포인트는 객체 내부에 자료형 필드와 함수가 함께 존재한다는 것입니다. 가능한 모든 물리적, 논리적 요소를 객체로 만드는 것이 객체지향 프로그래밍입니다.
위에서 가정한 도서 관리 프로그램도 객체지향으로 구현하게 되면 첵의 제목, 저자, 페이지 수와 같은 자료형 필드와 대출하기, 반납하기 등의 메소드를 책이라는 객체에 하나로 묶어 관리하는 것이 가능해집니다. 이렇게 되면 추상적이었던 동작도 훨씬 직관적으로 보이게 되어 코드 가독성이 올라갑니다.
객체지향 프로그래밍을 통해 객체 간의 독립성이 뚜렷하게 생기고 중복되는 코드의 양이 줄어듭니다. 따라서 유지보수에 큰 이점을 가지게 됩니다.

객체지향 프로그래밍(Object-Oriented Programming)의 4가지 특징

  1. 추상화(Abstraction)

    • 객체들이 공통적으로 필요로 하는 속성이나 동작을 하나로 추출해내는 작업입니다.
    • 추상적인 개념에 의존하여 설계해야 코드의 유연함을 갖출 수 있습니다.
    • 즉, 세부적인 사물들의 공통적인 특징을 파악한 후, 하나의 묶음으로 만들어내는 것이 추상화입니다.
  2. 캡슐화(Encapsulation)

    • 정보 은닉화를 통해 높은 응집도, 낮은 결합도를 유지할 수 있도록 설계하는 것입니다.
      • 은닉화는 외부에서 접근할 필요 없는 요소는 접근 지정자를 private로 두어 접근에 제한을 두어 구현합니다. 이를 통해 외부 객체는 객체 내부의 구조를 모르게 하고, 해당 객체가 노출해서 제공하는 필드와 메소드만 이용할 수 있도록 하여 의도하지 않은 동작 오류를 방지하고 유지보수 효율을 높일 수 있습니다.
    • 한 곳에서 변화가 일어나도 다른 곳에 미치는 영향을 최소화시키는 것을 의미합니다. 즉, 객체 내부의 어떤 동작이 어떻게 되어있는지 감추는 것입니다. 이를 통해 외부에서 무엇인가를 잘못 건드려 객체를 손상시키는 일을 방지할 수 있습니다.
    • 결합도는 어떤 기능을 실행할 때 다른 클래스나 묘듈에 얼마나 의존적인지를 나타내는 지표입니다. OOP는 객체 간의 독립성을 강조하기 위해 등장했기 때문에 결합도를 낮춰야만 합니다.
    • 독립적으로 만들어진 객체들 간의 의존도가 최대한 낮게 만드는 것이 중요합니다. 소프트웨어 공학적으로 객체 내의 모듈 간의 요소가 서로 밀접한 관련이 있는 것으로 구성하여 응집도를 높이고, 서로 다른 모듈 간에는 결합도를 줄여야 요구사항 변경에 대처하는 좋은 설계라고 할 수 있습니다.
  3. 상속(Inheritance)

    • 여러 개체들이 지닌 공통된 특성을 부각시켜 하나의 개념이나 법칙으로 성립하는 과정입니다.
    • 자식 클래스가 부모 클래스의 필드와 메소드를 그대로 물려 받아 사용할 수 있게, 또는 조금 수정하여 사용할 수 있게 해주는 것입니다. 자식 클래스를 외부로부터 은닉하는 캡슐화의 일종이기도 합니다.
    • 상속 관계에서는 단순히 하나의 클래스 안에서 속성, 메소드들의 캡슐화에 한정되지 않고, 자식 클래스 또한 캡슐화되어 외부 클래스에 은닉하는 것으로 개념이 확장됩니다. 자식 클래스를 캡슐화하게 되면 외부에서는 개별적인 자식 클래스들과 무관하게 개발을 이어갈 수 있는 장점이 있습니다.
    • 상속을 활용하면 상위 클래스의 구현을 활용함으로써 코드 재사용성이 증가합니다.
    • 하지만 상속을 통한 재사용을 할 때 나타나는 단점도 명확해지므로, 객체지향 프로그래밍에서 ‘코드 재사용’을 목적으로 하는 상속 행위는 엄격히 금지합니다.
      • 부모 클래스의 변경이 불편해짐: 부모 클래스에 의존하는 자식 클래스가 많을 때 부모 클래스를 변경하게 되면 이를 의존 하는 자식 클래스들이 영향을 받게 됩니다.
      • 불필요한 클래스의 증가: 유사 기능 확장시, 필요 이상의 불필요한 클래스를 만들 가능성이 있습니다.
      • 잘못된 상속 사용: 같은 종류가 아닌 클래스의 구현을 재사용하기 위해 상속을 받게 되면 문제가 발생할 수 있습니다. 상속받는 클래스가 부모 클래스와 is-a 관계가 아닐 때 발생합니다. 이는 구성(Composition)을 통해 해결할 수 있습니다.
        • 객체 컴포지션은 객체 내부 필드에서 다른 객체를 참조하는 방식으로 구현합니다. 상속에 비해 런타임 구조가 복잡하고 구현이 어렵지만, 변경시 유연함을 확보할 수 있다는 장점이 큽니다.
    • 따라서 상속은 반드시 is-a 관계가 성립하고, ‘재사용 관점’이 아닌 ‘기능의 확장 관점’에서 사용해야 합니다. 상속은 코드 재사용의 개념으로 사용하면 안됩니다. 클래스 간 결합도가 높아져 유지보수에 어려움을 겪게 될 가능성이 높아집니다. 상속은 일반적인 개념을 구체화하는 상황에서 사용해야 합니다.
      • is-a: 포함 관계를 의미하며, 한 클래스 A가 다른 클래스 B의 자식 클래스임을 의미합니다.
      • has-a: 상속이 아닌 구성(Composition) 관계를 의미하며, 한 객체가 다른 객체에 속한다는 의미입니다.
  4. 다형성(Polymorphism)

    • 객체지향 패러다임의 핵심으로 서로 다른 클래스의 객체가 같은 동작 수행 명령을 받았을 때, 각자의 특성에 맞는 방식으로 동작하는 것을 의미합니다.
    • 다형성은 상속과의 시너지가 엄청납니다. 다형성 구현을 통해 코드를 간결하게 해주고 유연성을 갖추게 해줍니다.
    • 현재 어떤 클래스 객체가 참조되는지와 무관하게 프로그래밍하는 것이 가능합니다.
    • 상속 관계에 있다면, 새로운 자식 클래스가 추가되어도 부모 클래스의 함수를 참조해오면 되기 대문에 다른 클래스는 영향을 받지 않게 됩니다.

객체지향 설계원칙

객체지향의 설계 과정은 다음과 같습니다. 1. 요구사항을 찾고 세분화한다. 그 기능을 알맞은 객체로 할당한다. 2. 기능을 구현하는 데에 필요한 데이터를 객체에 추가한다. 3. 해당 데이터를 이용하는 기능을 구현한다.(캡슐화) 4. 객체 간에 어떻게 메소드 호출을 주고 받을 지 결정한다.

SOLID라고 부르는 5가지의 설계원칙이 존재합니다. 1. SRP(Single Responsibility) - 단일 책임 원칙으로 클래스는 단 한개의 책임을 가져야 하며 클래스를 변경하는 이유도 단 하나여야 합니다. - 이를 지키지 않으면 한 책임(기능)의 변경에 의해 다른 책임과 관련된 코드에 영향을 미칠 수 있어 유지보수가 매우 비효율적입ㄴ디ㅏ. 2. OCP(Open-Closed) - 개방-폐쇄 원칙으로 확장에는 열려있어야 하고, 변경에는 닫혀있어야 합니다. - 기존의 코드를 변경하지 않고 기능을 수정하거나 추가할 수 있도록 설계해야 합니다. - 어떤 모듈의 기능을 하나 수정할 때, 그 모듈을 이용하는 다른 모듈들을 모두 고쳐야한다면 유지보수가 복잡해집니다. - 그렇게 되면 OOP의 장점인 유연성, 재사용성, 유지보수성을 모두 잃어버리게 되며, OOP를 사용하는 의미가 사라지게 됩니다. - 이를 지키지 않으면 instanceof와 같은 연산자를 사용하거나 다운 캐스팅이 발생합니다. - 추상화(인터페이스)와 상속(다형성)을 통해 자주 변화하는 부분을 추상화함으로써 기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 해 유연함을 높이는 것이 핵심입니다. 3. LSP(Liskov Substitution) - 리스코프 치환 원칙으로 하위 타입 객체는 상위 타입 객체에서 가능한 행위를 수행할 수 있어야 합니다. 이때 상위 타입 객체를 하위 타입 객체로 치환해도 정상적으로 동작해야 합니다. - 상속 관계에서는 일반화 관계(is-a)가 무조건 성립해야 하며, 상속 관계가 아닌 클래스들을 상속 관계로 설정하면(재사용 목적으로 사용하는 경우) 이 원칙에 위배됩니다. - 리스코프 치환 원칙을 지키지 않으면 개방-폐쇄 원칙을 위반하게 되며, 기능 확장을 위해 기존의 코드를 여러번 수정해야 하는 상황에 놓일 수 있습니다. 4. ISP(Interface Segregration) - 인터페이스 분리 원칙으로 클라이언트는 자신이 사용하는 메소드에만 의존해야 한다는 원칙입니다. - 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 않아야 합니다. - 이때 통상적인 하나의 인터페이스보다는 여러개의 세부적, 구체적인 인터페이스가 낫습니다. - 인터페이스는 해당 인터페이스를 사용하는 클라이언트를 기준으로 잘게 분리되어야 합니다. - 각 클라이언트가 필요로 하는 인터페이스들을 분리하여 클라이언트가 사용하지 않는 인터페이스에 변경이 발생하더라도 영향을 받지 않도록 만들어야 한다는 것이 핵심입니다. 5. DIP(Dependency Inversion) - 의존 역전 원칙으로 의존 관계를 맺을 때 변하기 쉬운 것(구체적인 것) 보다는 변하기 어려운 것(추상적인 것)에 의존해야 합니다. - 구체화된 클래스에 의존하기 보다는 추상 클래스나 인터페이스에 의존해야 한다는 의미이며, 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안됩니다. - 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 하며, 이는 결국 저수준 모듈이 변경되어도 고수준 모듈은 변경이 필요없는 형태로 구현해야 한다는 의미입니다.

함수형 프로그래밍

최근의 프로그래밍 패러다임은 크게 아래와 같이 구분할 수 있습니다.

  • 명령형 프로그래밍: 무엇(What)을 할 것인지 나타내기 보다 어떻게(How) 할 것인지 설명하는 방식
    • 절차지향 프로그래밍: 수행되어야 할 순차적인 처리 과정을 포함하는 방식(C, C++)
    • 객체지향 프로그래밍: 객체들의 집합으로 프로그램의 상호작용을 표현(C++, Java, C#)
  • 선언형 프로그래밍: 어떻게(How) 할 것인지를 나타내기보다 무엇(What)을 할 것인지 설명하는 방식
    • 함수형 프로그래밍: 순수 함수를 조합하고 소프트웨어를 만드는 방식(클로저, 하스켈, 리스프)

명령형 프로그래밍은 소프트웨어의 크기가 커지게 될 수록 스파게티 코드가 될 가능성이 높아지고, 그에 따라 유지보수하기 어려워집니다. 이를 해결하기 위해 만들어진 것이 함수형 프로그래밍입니다. 함수형 프로그래밍은 모든 것을 순수 함수로 나누어 문제를 해결하는 기법으로, 작은 문제를 해결하기 위한 함수를 작성하여 가독성을 높이고 유지보수를 용이하게 해줍니다. 클린 코드의 저자는 함수형 프로그래밍을 대입문이 없는 프로그래밍이라고 정의하였습니다.
process(10, print(num))과 같은 수도코드가 있다고 합시다. process 함수는 첫번째 인자로 몇까지 iteration을 돌 것인가를 매개변수로 받고 있고, 두번째 인자로 전달받은 값을 출력하라는 함수를 매개변수로 받고 있습니다. 함수형 프로그래밍은 무엇(What)을에 초점을 두는 프로그래밍 패러다임이므로 ‘출력을 하는 함수’를 파라미터로 넘길 수 있으며, 이는 함수형 프로그래밍의 기본 원리 중에서 ‘함수를 1급 시민 또는 1급 객체로 관리’하는 특징 때문입니다.
명령형 프로그래밍에서는 메소드를 호출하면 상황에 따라 내부의 값이 바뀔 수 있지만, 함수형 프로그래밍에서는 대입문이 없기 때문에 메모리에 한번 할당된 값은 새로운 값으로 변할 수 없습니다.

함수형 프로그래밍의 특징

부수 효과(Side Effect)가 없는 순수 함수(Pure Function)1급 객체로 간주하여 파라미터나 반환값으로 사용할 수 있으며, 참조 투명성을 지킬 수 있습니다.

  • 부수 효과(Side Effect)
    • 다음과 같은 변화 또는 변화가 발생하는 작업을 의미합니다.
      • 변수의 값이 변경됨
      • 자료 구조를 제자리에서 수정함
      • 객체의 필드값을 설정함
      • 예외나 오류가 발생하며 실행이 중단됨
      • 콘솔 또는 파일 I/O가 발생함
  • 순수 함수(Pure Function)
    • 부수 효과(Side Effect)를 제거한 함수를 의미하며, 함수형 프로그래밍에서 사용하는 함수는 이러한 순수 함수들입니다.
      • 메모리 또는 I/O 관점에서 부수 효과가 없는 함수
      • 함수의 실행이 외부에 영향을 끼치지 않는 함수
    • 함수 자체가 독립적이며 부수 효과가 없기 때문에 Thread에 안정성을 보장받을 수 있습니다.
    • Thread에 안정성을 보장받으므로, 병렬 처리를 동기화 없이 진행할 수 있습니다.
  • 1급 객체(First-Class Object)
    • 다음과 같은 것들이 가능한 객체를 의미합니다.
      • 변수나 데이터 구조 안에 담을 수 있음
      • 파라미터로 전달할 수 있음
      • 반환값으로 사용할 수 있음
      • 할당에 사용된 이름과 무관하게 고유한 구별이 가능함
    • 함수형 프로그래밍에서 함수는 1급 객체로 취급받기 때문에 위와 같은 작업이 가능하며, 우리가 일반적으로 알고 개발해온 함수들은 함수형 프로그래밍에서 정의하는 순수 함수들과는 다름을 알아야 합니다.
  • 참조 투명성(Referential Transparency)
    • 동일한 인자에 대해 항상 동일한 결과를 반환해야 합니다.
    • 참조 투명성을 통해 기존의 값은 변경되지 않고 유지되어야 합니다.(Immutable Data)
    • 명령형 프로그래밍과 함수형 프로그래밍에서 사용하는 함수는 부수효과의 유무에 따라 차이가 있습니다. 그에 따라 함수가 참조에 투명한지 아닌지 나뉘어지는데, 참조에 투명하다는 것은 함수를 실행해도 어떤 상태의 변화 없이 항상 동일한 결과를 반환하여 항상 동일(투명)하게 실행 결과를 참조(예측)할 수 있다는 의미입니다.
    • 함수형 프로그래밍에서 부작용을 제거하여 프로그램의 동작을 이해하고 예측을 용이하게 만들어주는 요소입니다.
    • 병렬 처리 환경에서 개발할 때 Race Condition에 대한 비용을 줄여주는데, 이는 함수형 프로그래밍에서는 값의 대입이 없이 항상 동일한 실행에 대해 동일한 결과를 반환하기 때문입니다.

참고