
처음 개발에 입문해 개발공부를 시작할때는 요구사항을 만족시키는 기능을 구현하는것을 가장 중요한 목표로 삼습니다. 하지만 개발 경험이 쌓여갈수록 기능은 어떻게든 구현 할 수 있겠다는 생각이 들게되고 동시에 중요한 목표가 좋은 코드를 작성하는것으로 변화합니다. 왜냐하면 좋은 코드에 대한 고민 없이 만들어진 프로그램은 변경되는 요구사항을 반영하기 매우 어렵기 때문입니다.
좋은 코드에 대해 학습하다보면 다양한 이론과 개념을 접하게 되지만 반드시 한번은 들어볼 수 밖에 없는 중요한 패러다임이 두가지가 있습니다. 바로 OOP(객체지향 프로그래밍)와 FP(함수형 프로그래밍)입니다.
두가지 패러다임 모두 좋은 코드를 작성하는 많은 방법과 이론에 영향을 주는 만큼 학습의 필요성을 크게 느끼게 되지만, 쉽지 않은 난이도로 인해서 처음 접하였을때는 실제 코드에 적용하기까지 많은 시간이 걸릴것 같다는 생각 때문에 학습을 시작하기 망설여지는 경우가 있습니다.
하지만 OOP와 FP의 중요한 개념 몇가지만 이해하더라도 코드에 이를 반영해 더 좋은 코드를 작성할 수 있는 만큼 가벼운 마음으로 두가지 패러다임의 학습을 시작해보는것이 좋다고 생각합니다.
이번 아티클에서는 OOP와 FP의 핵심 개념을 소개하고, Javascript를 사용하는 프론트엔드에서 OOP와 FP의 개념을 이용해 어플리케이션을 만드는 간단한 예제를 통해 개념을 적용해보는 방법을 알아봅니다.
설명을 위해 모든 예제는 Javascript가 아닌 Typescript로 작성되었습니다.
OOP와 FP 살펴보기
OOP(객체지향 프로그래밍)
객체지향 프로그래밍은 책임을 수행하는 자율적인 객체들의 협력을 통해 애플리케이션을 구축 하는 방식입니다.
이를 통해 객체지향을 곧바로 이해하기는 쉽지 않기에 조금더 풀어보자면 객체는 자신이 해야하는 책임과 관련된 기능만을 제공하면서, 기능을 제공하기 위한 정보와 제공하지 않지만 유용한 기능을 내부적으로 보관해두고 잘 모르는 정보나 기능은 더 잘아는 객체에 물어보는 협력을 통해 동작하도록 하는 방식을 말합니다.
이러한 정의만으로 이해가 되지 않는다면 객체가 데이터와 기능을 가진 작은 프로그램과 유사하고 작은 프로그램이 협력하여 더 큰 프로그램을 만들어낸다고 이해할 수도 있습니다. 예를 들어 프론트개발자, 백엔드개발자, 디자이너, 기획자 각각이 자신 이 해야하는 일(프론트는 웹사이트개발, 백엔드는 api개발 등등..)을 하면서 자신이 잘모르는 영역은 잘아는 직군에게 물어보는 협력(프론트가 백엔드에게 api의 query param을 물어보기)을 통해 프로젝트를 만드는것과 유사하다고도 볼 수 있습니다.
이제 본격적으로 객체지향의 네가지 주요개념인 추상화, 캡슐화, 상속, 다형성에 대해서 알아보면서 객체지향에 대한 이해를 높여보겠습니다.
추상화
추상화는 복잡성의 한계를 극복하기 위하여 만들어진 도구로 복잡한 대상을 보다 단순하게 만드는 방식을 말합니다. 사실 추상화 자체는 객체지향의 전유물이 아니라 컴퓨터 공학전반에서, 더나아가 일상적으로도 자주 사용되는 개념입니다. 우리가 흔히 사용하는 Javascript와 같은 고수준언어는 0 과 1로 이루어진 기계어의 추상화이며, 스마트폰 이라는 단어는 갤럭시, 아이폰등을 일반화한 추상적 개념입니다.
객체지향에서의 추상화는 구체적인 사물들간의 공통점을 취하는 첫번째 단계와, 중요한 부분을 강조하기위해서 불필요한 세부사항을 제거하는 두번째 단계로 이루어집니다.
갤럭시, 아이폰을 예시로 들어보겠습니다. 첫번째로 둘은 서로 다른 모양과 기능을 가졌지만, 스마트폰 이라는 단어로 일반화 가능합니다. 두번째로 스마트폰의 모든 개념을 가져다 사용하지 않아도 됩니다. 어플설치, 사진촬영, 위치 확인등 수많은 기능이 있지만, 프로그램 구현에 필요한 개념이 전화와 문자기능이라면 해당 기능 이외의 공통점은 무시합니다.
캡슐화
캡슐화는 관련성이 높은 데이터와 행위를 묶어 객체내부로 숨기는것을 의 미합니다. 이때 숨겨지는것은 구현으로, 실제 다른 객체와의 메시지 송수신을 위한 인터페이스는 유지하게 됩니다.
이러한 캡슐화를 통해서 다른 객체가 해당 객체의 구체적인 부분인 구현에 의존하지 않고 인터페이스에 의존함으로써 추후 해당 객체의 구현을 변경했을때 해당 변경이 다른 객체로 전파되지 않도록 할 수 있습니다.
// 계좌이용에 필요한 입금과 인출 기능만 외부로 노출하고 인스턴스 변수는 노출하지 않습니다.
// 만약 인스턴스 변수도 외부로 노출할경우 변수명을 변경하는것 만으로도 이 변수를 참조하는 모든 클래스를 수정해야합니다.
class Account {
private bank:Bank;
private money: number;
private accountNumber: string;
constructor(bank:Bank,accountNumber: string) {
this.money = 0;
this.bank = bank;
this.accountNumber = accountNumber;
}
withdraw(amount: number) {
if (amount > this.money) return false;
this.money -= amount;
return true;
}
deposit(amount: number) {
this.money += amount;
return true;
}
}
상속
상속은 재사용을 위한 가장 일반적인 방법으로 부모 클래스의 데이터와 기능을 자식클래스가 물려받는것을 의미합니다. 상속에는 두가지 종류가 있는데 자식 클래스가 부모 클래스의 인스턴스를 대체할 수 있는 인터페이스 상속과 자식클래스가 부모 클래스의 인스턴스를 대체할 수 없는 구현상속이 있습니다.
좀더 이해하기 쉽도록 풀어보자면 보통 개념적 상위의 클래스를 상속받는것을 인터페이스 상속이라고 부르며 그렇지 않은 상속을 구현상속이라고 부릅니다. 가령 아이폰이 스마트폰 클래스를 상속받는 경우 개념적 상위호환이 맞기에 인터페이스 상속입니다. 하지만 전화나 문자 기능을 위해 피처폰 클래스가 스마트폰 클래스를 상속받는 경우 피처폰 클래스는 자체는 어플관련 기능들을 사용할 수 없지만 부모 인터페이스에는 어플 관련 기능이 있기 때문에 피처폰에서 어플 관련 메서드를 사용할 위험성이 생기는데 이를 구현상속이라고 합니다.
구현상속을 사용하게 되면 이후 언급하게 될 다형성을 사용하기도 어렵고, 부모클래스에 강결합 되기 때문에 가능한 인터페이스 상속을 이용하는것이 좋습니다. 만약 인터페이스 상속이 불가능한 상황에서 코드 재사용이 필요한 경우 구현상속보다는 해당 클래스에서 필요한 메서드만 가져와 사용하는 합성을 사용하는 것이 좋습니다.
// 인터페이스 상속을 사용한 사례
// IPhone과 GalaxyPhone 모두 Phone 클래스의 메서드를 사용가능하므로 인터페이스 상속입니다.
abstract class Phone {
abstract call: () => void;
abstract sendMessage: () => void;
}
class IPhone {
call() {
// IPhone 만의 전화 거는 로직
}
sendMessage() {
// IPhone 만의 전화 거는 로직
}
}
class GalaxyPhone {
call() {
// GalaxyPhone 만의 전화 거는 로직
// 통화 녹음 기능 추가
this.callRecording();
}
sendMessage() {
// GalaxyPhone 만의 전화 거는 로직
}
private callRecording() {
// GalaxyPhone 만의 전화 거는 로직
}
samsungpay() {
// 삼성페이 기능
}
}
/* 구현 상속을 사용한 사례 */
/* Pencil 클래스의 write기능을 재사용하기 위해서 상속을 사용했지만
사용하면 안되는 sharpen 기능또한 인터페이스에 포함되어버리므로 이는 구현상속입니다.*/
class Pencil{
write(){
// 글을 쓰는 기능
}
sharpen(){
// 연필을 깍는 기능
}
}
// 상속을 사용하면 sharpen 메서드가 인터페이스에 노출됩니다.
class Pen extends Pencil{
use(){
// 펜을 사용할 수 있도록 뚜껑을 열거나 버튼을 누르는 기능
}
}
// 합성을 사용하면 sharpen 메서드는 더이상 인터페이스에 노출되지 않습니다.
class Pen {
pencil:Pencil
constructor() {
this.pencil = new Pencil();
}
use() {
// 펜을 사용할 수 있도록 뚜껑을 열거나 버튼을 누르는 기능
}
write(){
this.pencil.write()
}
}
다형성
객체지향의 가장 중요한 개념으로 상위 클래스 타입의 참조변수로 하위 클래스의 객체를 참조할수 있게 해주는 것을 말합니다. 이는 실행할 프로시저가 런타임에 결정되도록 해주기 때문에 실행할 프로시저가 컴파일 타임에 결정되는 절차지향형 언어와 가장 대비되는 특징이기도 합니다.
변수에 상위 클래스 타입을 사용함을 명시해두고 해당 변수의 타입으로 하위 클래스 타입을 사용하더라도 아무문제가 없기에 변수를 사용하는 쪽에서 모든 하위 클래스 타입을 고려하지 않고 설계를 할 수 있게됩니다. 따라서 이후 새롭게 타입이 추가되더라도 기존 코드를 고치지 않고 새로운 클래스를 추가함으로써 새로운 타입을 손쉽게 추가할수 있습니다.
// 등급별로 할인율, 할인 대상이 다릅니다.
abstract class Grade {
private discountRate: number;
private discountAbleList: Item[];
constructor(discountAbleList: Item[], discountRate: number) {
this.discountAbleList = discountAbleList;
this.discountRate = discountRate;
}
isDiscountAble(item: Item) {
this.discountAbleList.find((listItem) => listItem.isSame(item));
}
addDiscountAble(item: Item) {
this.discountAbleList.push(item);
this.observeDiscountAbleListChange();
}
deleteDiscountAble(item: Item) {
this.discountAbleList = this.discountAbleList.filter(
(listItem) => !listItem.isSame(item)
);
this.observeDiscountAbleListChange();
}
discountPrice(item: Item) {
item.discount(this.discountRate);
}
protected observeDiscountAbleListChange() {}
}
class Silver extends Grade {}
// 골드 등급은 할인 목록 변경시 이메일로 알려주는 서비스를 제공합니다.
class Gold extends Grade {
observeDiscountAbleListChange() {
this.sendEmail()
}
sendEmail() {
// 이메일을 보내는 로직
}
}
// Grade 타입을 명시했지만, 이를 상속받은 Silver를 사용하던, 추가적인 메서드가 존재하는 Gold를 사용하던 관계가 없습니다. 왜냐하면 이두가지 타입모두 Grade에서 정의한 메시지를 이해할수 있기 때문입니다.
const fooGrade:Grade = new Silver()
const barGrade:Grade = new Gold()
FP(함수형 프로그래밍)
함수형 프로그래밍은 애플리케이션의 부수효과를 방지하고 상태 변이를 감소하기 위해 데이터의 제어흐름과 연산을 추상화하는것입니다.
조금더 쉽게 풀어 보자면 상태를 객체내부에 가두어두지 않고 외부에 공개해두지만 해당 상태를 직접적으로 변경시키는 부수효과를 최소화하고, 상태 변경 과정에서 발생하는 연산을 선언적 함수(map, filter, reduce)등의 체이닝으로 표기하는것을 의미합니다.
이러한 정의만으로 함수형 프로그래밍을 이해하기는 어렵기에 함수형 프로그래밍을 관통하는 키워드인 순수함수, 부수효과, 불변성, 선언형에 대해 살펴보겠습니다.
순수함수와 부수효과
순수함수는 인자로 주어진 입력만을 이용하고 전역 범위의 변수를 수정하거나, DB나 HTML의 데이터를 조회하는등의 부수효과(side Effect)를 일으키지 않는 함수를 말합니다.
순수함수를 정의하는 조금더 공식적인 명칭은 참조 투명성으로 특정함수가 동일한 입력을 받았을때 동일한 결과를 내는경우를 말합니다. 수학에서 사용하는 함수와 동일한 개념입니다.
이러한 순수함수는 테스트하기 쉽고 결과를 예측하기 쉽다는 장점이 존재하기에, 가능한 함수에서 부수효과를 분리하여 순수함수를 많이 만드는것이 좋습니다.
하지만 파일 입출력이나 db조회등의 부수효과가 없는 순수함수로만 이루어진 프로그램은 아무런 의미가 없기때문에 함수형 프로그래밍은 순수함수로만 이루어진 프로그램을 구성하는것이 아니라 최대한 많은 순수함수로 이루어진 프로그램을 구성하는것을 말합니다. 때문에 함수형 프로그래밍의 많은 기법들이 기존 함수에서 분리된 부수효과를 관리하는 방법과 관련이 높습니다.
// 순수함수
function sum(a, b) {
return a + b
}
// 외부 변수를 참조하는 비순수함수
const a = 1
function sum(b) {
return a + b
}
// 외부 변수를 변경하고 참조하는 비순수 함수
let c = 1
function sum(a, b) {
c = a
return c + b
}
불변성
불변이란 메모리에 저장된 값을 직접적으로 변경하지 않고, 새로운 값을 메모리에 저장하여 값을 변경하는 방식입니다.
앞서 다루었던 순수함수를 만들때 고려해야 할 것중 한가지가 바로 불변(immutable)입니다. 왜냐하면 불변이 유지되지 않는 경우 의도하지 않게 외부 상태를 변경하여 부수효과를 초래하기 쉽기때문입니다.
자바스크립트에서 원시자료형은 언어 자체에서 불변을 지원하기에 값을 전달할때 복사하여 전달하지만, 참조형은 불변을 지원하지 않아서 값을 전달할때 메모리 주소를 전하기 때문에 메모리에 저장된 객체를 직접적으로 변경할수 있습니다. 따라서 불변을 유지하기 위해서는 객체를 수정할때 객체를 복사해 새로운 주소에 할당한 후 수정하여야 불변이 유지됩니다. 이를 직접 구현하여 사용할 수도 있지만 immutable.js와 같은 라이브러리를 이용하면 보다 쉽게 불변을 유지할 수 있습니다.
// 원시형을 넘겨받는경우
function sum(a, b) {
return a + b
}
const a = 1
const b = 2
console.log(sum(a, b)) // 3
// 참조형을 넘겨받는경우
function twice(input) {
for (let i = 0; i < input.length; i++) {
input[i].count *= 2
}
return input
}
const input = [
{ name: "foo", count: 1 },
{ name: "bar", count: 2 },
]
console.log(twice(input)) // [ { name: 'foo', count: 2 }, { name: 'bar', count: 4 } ] 예상 결과가 도출됩니다.
console.log(input) // ??? [ { name: 'foo', count: 2 }, { name: 'bar', count: 4 } ] 참조값이 변경되어 예상치 못한 결과가 도출됩니다.
일급
일급이란 값으로 사용할 수 있는것을 의미합니다. 따라서 일급은 변수나 파라미터에 할당할수 있고 함수의 return에도 사용할수 있습니다.
함수형 프로그래밍에서는 데이터의 제어흐름과 연산을 추상화하기위해서 일급이 아닌 것들을 일급인 함수로 만드는것을 중요시합니다. 다행인점은 자바스크립트에서는 함수가 일급이므로 일급이 아닌것을 함수로 만든다면 일급으로 취급할 수 있다는 점입니다.
일급이 아닌 함수이름, 연산자등을 일급으로 변환하게 되면 이제부터는 변수나 파라미터에 할당할수 있게 되고, 이를 통해 중복을 제거하거나, 연산의 흐름에 편입시킬수 있게 됩니다.
// 인자의 이름이 비슷한 함수들
function plusone(a) {
return a + 1
}
function plustwo(a) {
return a + 2
}
function plusthree(a) {
return a + 3
}
// 이름을 인자로 받기
// 이제 인자 이름을 b라는 값으로 관리할수 있습니다.
function plus(a, b) {
return a + b
}
// 연산자를 추상화
const a = b + c
const a = sum(b, c)
// 이제 연산자를 함수의 인자에 넘기거나 변수에 할당하는등 값으로 사용할 수 있습니다.
function sum(b, c) {
return b + c
}
const result1 = [1, 2, 3, 4, 5].reduce((prev, cur) => prev + cur) // 명령적으로 연산과정을 명시해야합니다.
const result2 = [(1, 2, 3, 4, 5)].reduce(sum) // 함수를 넘기므로 보다 선언적으로 연산과정을 명시할 수 있습니다.
선언형
선언형 프로그래밍은 명령형 프로그래밍과 반대되는 개념으로, 절차나 방식을 구체적으로 명시하기보다 동작만을 명시하는 프로그래밍 방식을 말합니다.
map
, filter
, reduce
와 같은 배열 메서드가 바로 선언적 문법입니다. for문을 사용하여 루프를 명시하지 않고 추상화하여 하나의 함수로 표현하기 때문입니다.
한편 선언형도 결국 명령형 위에서 동작하므로 결국 이는 명령형이 아니냐고 생각할수 있습니다. 실제로 컴퓨터는 결국 명령형 코드가 있어야 원하는 동작을 수행할 수 있기 때문에 선언형 코드도 명령형 코드를 포함하기 때문입니다. 하지만 선언형의 의미는 명령형 코드를 포함하지 않았다는것이 아니라 명령형 코드를 감추고 있다는것이 보다 적절합니다. 따라서 선언형은 가장 상위에서 명령형 코드를 추상적인 함수를 통해 표현하는 방식이라고 이해하면 되겠습니다.
const array = [1, 2, 3, 4, 5]
// 명령형
const result: number[] = []
for (let i = 0; i < array.length; i++) {
result.push(array[i] * 2)
}
// 선언형
array.map(input => input * 2)
OOP와 FP 비교하기
OOP와 FP에 대해서 간단하게 살펴보았으니 이제 두가지를 비교해보겠습니다. 표현문제라는 이론에 따르면 OOP의 경우 정해진 구조에서 새로운 타입을 추가시키는데 좋고, FP의 경우 새로운 기능을 추가시키는데 유리합니다. 반대로 OOP에서 새로운 기능을 추가하거나 FP에서 새로운 타입을 추가시키면 많은 코드를 수정해야합니다. 직관적이지 않은 문장들인만큼, 아래에서 예제와 함께 추가적으로 살펴보겠습니다.
OOP에서 표현문제
OOP를 설명할때 다형성이 가장 중요한 개념이라고 하였는데 이 다형성의 개념 덕분에 객체지향은 강력한 타입 확장성이라는 장점을 가지게됩니다. 다만 수많은 상속구조로 구성된 어플리케이션에서 새로운 기능을 추가하거나 변경 한다면 모든 클래스를 수정해야할지 모르기 때문에 새로운 기능 추가는 단점이됩니다.
아래 예제를 살펴보면 Vehicle
을 상속받은 자동차, 오토바이 클래스뿐만 아니라 추후 자전거, 비행기 등등 새로운 탈것 타입이 생기더라도 기존코드의 변경 없이 새로운 타입 클래스를 추가하는것만으로 탈것 타입을 확장시킬수 있어 타입을 확장하는것이 편리합니다.
하지만 run
과 stop
기능을 확장하여 위치를 변경하게 되는 기능을 추가하게된다고 가정해보겠습니다. 이때는 이 메서드를 사용하는곳에서의 영향도를 살펴보아야합니다. 현재는 부모함수에 기능을 추가함으로써 문제가 생길 이유가 없어보이지만, 만약 매우 많은 상속관계로 연결되어있는경우(Vehicle-Car-ElectricCar-BEV(전기차 한종류)와 같은 상속 구조인경우에서 Vehicle의 메서드 추가 또는 변경) 모든 연결된 클래스에서의 사용사례를 살펴보고 수정해야하므로 어려운일이 됩니다.
class Vehicle {
speed: number
constructor() {
this.speed = 0
}
run(speed: number) {
this.speed = speed
}
stop() {
this.speed = 0
}
}
class Car extends Vehicle {
window: boolean[]
constructor() {
super()
this.window = new Array(4).fill(false)
}
run(speed: number) {
super.run(speed)
console.log(`차량이 ${speed}의 속도로 출발합니다`)
}
stop() {
super.stop()
console.log("차량이 정지합니다")
}
openWindow(index: number) {
this.window[index] = true
}
closeWindow(index: number) {
this.window[index] = false
}
}
class motorCycle extends Vehicle {
run(speed: number) {
super.run(speed)
console.log(`오토바이가 ${speed}의 속도로 출발합니다`)
}
stop() {
super.stop()
console.log("오토바이가 정지합니다")
}
}
FP와 장단점
FP는 순수함수를 선언적으로 평가하기에 기존 항목에 새로운 기능을 추가하거나 변경할때 새로운 함수를 추가하거나 기존의 함수를 하나 수정하면 되므로 어려움이 없습니다. 하지만 새로운 타입을 추가하려면 관련된 모든 기능함수의 수정이 동반되어야 하므로 타입 을 추가하기가 쉽지 않습니다.
아례 예제는 도형에 관련된 기능들입니다. 현재는 길이를 측정하는 기능만 제공하지만, 넓이를 제공하는 기능이 추가되어야한다면 기존 코드를 수정하지 않고 넓이를 구하는 함수를 추가하면됩니다.
하지만 삼각형, 사각형밖에 존재하지 않는 타입이 늘어나 원, 오각형 등의 도형이 추가된다면 현재는 해당 타입을 사용하는 함수가 많지 않지만 만약 해당 타입을 사용하는 함수가 많을 경우 타입을 사용하는 모든 함수를 검토하고 수정해야하기에 어려운 일이 됩니다.
type Figure = "Triangle" | "Square"
type side = 3 | 4
const TriangleLength = (a, b, c) => a + b + c
const SquireLength = (a, b, c, d) => a + b + c + d
정리
결국 OOP와 FP 각각에 장단점이 존재하기에 멀티패러다임 언어인 Javascript에서는 OOP와 FP를 적절히 섞어 두가지 패러다임의 장점을 극대화 시키는게 좋다고 생각합니다. OOP만을 사용하여 과도한 상속 구조로 프로그램을 복잡하게 만드는것도, FP를 사용하여 잘 알려지지 않은 순수 함수들로 체이닝 하거나 고차함수를 작성하는것도 가독성이 떨어지기에 좋은 방식은 아니라고 생각하기 때문입니다.
Javascript에서 OOP와 FP를 적절하게 섞어 사용하기
Javascript에서 OOP와 FP를 섞어 사용하는 방식은 상황, 환경, 개인의 선호에 따라 다를수 있습니다. 따라서 이번 섹션에서는 다양한 방법중 제가 개인적으로 선호하는 방식을 소개하려고합니다. 당연히 정답이 아니고 다양한 방법중 한가지이기에 참고만 해주시면 좋겠습니다.
개인적으로 OOP와 FP를 섞어 사용한다고 하면 아키텍처 구조를 설계할땐 OOP관점을 적용하고 세부적인 기능을 구현할땐 FP관점을 적용해보는 방식을 선호합니다. 위 설명이 너무 추상적이라 이해가 어려울것같아 아래에서 보다 구체적인 어플리케이션 코드를 통해 살펴보겠습니다.
프론트엔드에서 개발을 하다보면 많은 영역이 api를 호출하여 응답을 가공해 화면에 보여주거나 혹은 api호출을 통해 데이터를 등록하거나 삭제하는 기능을 필요로 합니다. 이러한 기능을 위해 아키텍쳐를 세가지 영역으로 나누게 됩니다.
- repository: 백엔드의 api에 의존하는 영역으로 데이터를 받거나, 변경하는 api 호출을 담당합니다
- domain: data 영역에 의존하여 view에 맞는 데이터를 가공해내는 역할을 담당합니다.
- view: react, vue등의 ui라이브러리를 이용하여 데이터를 표현하는 코드가 포함된 영역으로 domain의 데이터를 받아 화면을 구성합니다.
각 영역이 책임을 가지고 메시지를 전송해 협력하므로 이를 객체지향이라고 볼 수 있습니다. 따라서 각 영역들의 구현이 변경되었을때 변경이 외부로 전파되지 않습니다. 예를 들어 백엔드의 엔드포인트 주소가 변경되었을경우 repository 영역에서만 변경하면되고, domain, view 영역에서는 변경이 필요하지 않으므로 검토도 필요없습니다.
한편 내부적으로 기능을 개발할때는 함수형 프로그래밍을 사용해보는것이 좋습니다. 객체지향에서 사용하는 함수는 대부분 메서드로 클래스에 강결합되어 this를 사용하는 부수효과가 발생하는 함수인 경우가 많습니다. 이러한 경우 재사용이 어렵고, 부수효과로 인해 디버깅이 어려워지므로, 순수함수의 체이닝으로 표현하는것이 좋습니다. 특히 view영역에서 상태를 관리하기 위해 사용하는 store에서는 함수형 패러다임이 적용된 redux, recoil 같은 라이브러리와 같이 사용한다면 더욱 효과가 좋다고 생각합니다.
repository 와 domain
repository, domain, view를 분리한 코드입니다. 단순히 분리만 한것이 아니라 관심사에 따라 응집도가 높고 결합도가 낮도록 분리하였기에 유지보수성이 높아집니다. 가령 like 기능을 localStorage가 아닌 서버에 저장하기위해서 백엔드와 통신해야할경우 request, response가 달라지지 않는다면 repository만 수정함으로써 변경이 가능합니다.
// 뉴스 api repository
class NewsRepository {
async getNewsList(
requestDto: getNewsListRequestDto
): Promise<getNewsListResponseDto> {
console.log(import.meta.env.VITE_API_KEY);
const requestQuery = getQueryString({
...requestDto,
apiKey: import.meta.env.VITE_API_KEY,
});
const data = await fetch(
`https://newsapi.org/v2/top-headlines?${requestQuery}`
);
const toJson = await data.json();
return toJson;
}
}
export default NewsRepository;
// 좋아요 관련 repository
class LikeRepository {
private key: string;
constructor() {
this.key = "newsLike";
}
getLikeCardList() {
return getDataFromLocalStorage<string[]>(this.key);
}
postLikeNews(title: string) {
const data = getDataFromLocalStorage<string[]>(this.key);
const addedData = [...data, title];
setDataFromLocalStorage(this.key, addedData);
return addedData;
}
deleteLikeCard(title: string) {
const data = getDataFromLocalStorage<string[]>(this.key);
const deletedData = data.filter((existId) => existId !== title);
setDataFromLocalStorage(this.key, deletedData);
return deletedData;
}
}
export default LikeRepository;
// View 영역 List 내부에서 데이터를 불러오는 로직이 존재합니다.
const Page = () => {
return (
<>
<Search />
<List />
</>
);
};
export default Page;
view와 store
앞서 보여드린 view의 경우 컴포넌트 두개를 렌더링하는 페이지 컴포넌트였지만 내부적으로 데이터를 처리하기위해서 store와 hooks을 사용해 관심사를 분리하는 한편 내부 로직의 경우 함수형 패러다임을 적용해보려 하였습니다.
// 스토어
import { atom } from "recoil";
export const searchForm = atom({
key: "searchForm",
default: {
country: "kr",
category: "general",
q: "",
},
});
export const searchResultList = atom<
{
title: string;
content: string;
publishedAt: string;
url: string;
image: string;
author: string;
like: boolean;
}[]
>({
key: "searchResultList",
default: [],
});
// search-hook
const useSearchHooks = () => {
const [searchFormValue, setSearchFormValue] = useRecoilState(searchForm);
const setsearchResultListValue = useSetRecoilState(searchResultList);
const listDomain = new ListDomain();
const changeFormElement =
(type: "country" | "category" | "q") =>
(event: ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
setSearchFormValue((oldSearchValue) => ({
...oldSearchValue,
[type]: event.target?.value,
}));
};
const onChangeCountryHandler = changeFormElement("country");
const onChangeCategoryHandler = changeFormElement("category");
const onChangeQHandler = changeFormElement("q");
const clickSearchButtonHandler = async () => {
const response = await listDomain.getNewsList(searchFormValue);
setsearchResultListValue(response);
};
return {
searchFormValue,
onChangeCountryHandler,
onChangeCategoryHandler,
onChangeQHandler,
clickSearchButtonHandler,
};
};
export default useSearchHooks;
기본적으로 상태는 페이지와 같은 레벨에 있는 스토어에 저장하고, 각 컴포넌트에 연결된 hooks에서 해당 스토어의 데이터를 가져와 상태 변경과 관련된 비즈니스 로직을 작성합니다. 그리고 view에서 hooks를 이용하여 UI를 그리게 됩니다. 이때 가능하면 store나 hooks에서 함수형 프로그래밍 기법을 이용해보면 좋습니다. 여기서는 search-hooks의 formElement
함수에 커링을 적용하였습니다.
커링이란 다중인수를 받는 함수를 하나의 인수를 받는 함수로 변환하는 것을 말합니다. formElemet
함수는 type
과 value
모두 필요한데, value
는 이벤트 받아올수 있는 값인 반면 type
은 먼저 결정되어지는 값이므로, 커링을 이용해 type
을 먼저 결정해둔 함수를 만들고 이를 이벤트핸들러에 할당하였습니다. 물론 이경우 일반적으로 커링을 이용하는것 처럼 중복을 제거하는 용도로 사용한것은 아니고 핸들러 함수를 보다 깔끔하게 표기하기 위해 사용하였습니다.
커링과 같은 기법이 아니더라도, 상태를 불변으로 관리하고, 함수에서 부수효과를 분리하여 순수함수를 생성하는 등의 기본적인 함수형 패러다임을 적용하게되면 이후 상태의 흐름을 파악하는데 도움이 되므로 코드 파악이나 디버깅에 용이하다는 장점이 있다고 생각합니다.
예제코드가 포함된 프로젝트의 전체 코드는 github 저장소를 확인 해주세요
마치며
객체지향은 부수효과를 최대한 캡슐화 하여 응집도 높은 객체를 지향하고, 함수형은 부수효과를 최대한 분리하여 철저하게 관리하는 방식을 지향합니다. 두가지 패러다임이 상충하는 관계가 아니라 상호보완적 관계인 만큼 자바스크립트와 같은 멀티 패러다임 언어에서는 이 두가지를 잘 섞어서 사용하는것이 좋다고 생각합니다.
두가지를 섞어 사용할때는 우리의 프로그램에서 변경가능성이 높은 부분이 어디인지를 도메인과 결합하여 생각한 뒤 각 패러다임의 장점을 극대화 시키는 방향으로 적용해보면 좋을것 같습니다.
다만 변하지 않는 프로그램을 작성할때는 OOP와 FP보다 절차지향형이 더 좋을 수 있기 때문에 모든 프로그램에 만능인 silver bullet(은 탄환)은 없음을 이해하고 OOP와 FP 또한 좋은 코드를 작성하기위한 하나의 도구 정도로 이해하면 좋을것 같습니다.
참고자료
Functional programming vs Object Oriented programming Expression problem related to OO and FP 변하지 않는 상태를 유지하는 방법, 불변성(Immutable) 자바스크립트에서 객체지향을 하는 게 맞나요? '액션-계산-데이터' 관점으로 보는 함수형 프로그래밍 패러다임 객체지향의 사실과 오해 (조영호 저) 오브젝트 (조영호 저) 쏙쏙 들어오는 함수형 코딩 (에릭 노먼드 저)