티스토리 뷰

`단일 책임 원칙(Single Responsibility Principle)`을 이제 대부분 알고 있을 것이다. 아니면, 한 번쯤 들어봤을 것이다.

 

`단일 책임 원칙`은 각 소프트웨어 모듈은 변경의 이유가 단 하나여야 한다는 규칙을 가지고 있다. 즉, 하나의 모듈은 하나의 사용자 또는 이해관계자에 대해서만 책임을 져야 한다는 것이고, 하나의 요소가 하나의 변경의 이유를 갖게 하라는 말이다.

 

쉽게 말해, 대표가 1명인 개발자와 대표가 4명인 개발자 중 대표가 4명인 개발자는 어떻게 개발해 나가야 하는지 개발 방향이 4명의 대표마다 달라 혼란스러울 것이다. 이를 방지하자는 것이다.

 

그리고 여기에서 `응집성`이 중요한데, 이는 단일 액터를 책임지는 코드를 함께 묶어주는 힘을 말한다.

 

만약, 다음과 같은 요구사항이 있다고 가정해보자.

 

  • 특정 유저의 서비스 접근을 3회 거쳐 단계적으로 종료
  • 일자 별로 접근 불가능해야 하는 페이지 추가
  • 일자 별로 노출되지 말아야 하거나 작동되지 말아야 할 기능 추가

 

핵심적으로 구현해야 할 것은 권한 체크를 위한 `PermissionRoute 컴포넌트`를 추가하고, 요구 사항을 담은 테스트 코드와 관련 로직들을 함께 하나의 폴더 안에 응집될 수 있도록 모아두는 방식으로 관리해야 할 것이다.

// SRP를 준수한 예시 코드
// 구현 예시 - 구체적인 맥락은 담지 않고 유저의 권한을 체크하여 리다이렉트 하는 로직만 담당
const PermissionRoute = (props) => {
	const user = useUser();
	const permission = useUserPermission();

	// 타입 레벨에서 deny, only 둘 중 하나만 받을 수 있도록 제약함
	if (
		props.deny({ user, permission }) ||
		!props.only({ user, permission })
	) {
		errorToast(...);
		return <Navigate replace to={...} />
	}
	return props.children
}

<PermissionRoute deny={({ permission }) => permission.user.tier < 10}>
	<Route />
</PermissionRoute>

 

추상화

이제 비즈니스 코드를 어떻게 분리해야 할까? 생각해야 할 것은 바로 `추출`과 `추상화`이다.

단순히 함수만 분리한다고 분리되는 것이 아니다. 이는 코드의 양을 줄이고 더 읽기 쉽게 만들어주는 것에 그친다.

 

중요한 것은 `추출`과 `추상화`를 잘 하는 것이다. 이것이 왜 중요하냐면, 바로 `모듈성`과 관련되어 있기 때문이다. 코드의 독립된 부분들(모듈)들이 잘 연결되고, 각각 독립적으로 기능할 수 있도록 해야 한다. 그렇다면, 각 모듈은 재사용이 가능하고, 교체가 가능해지며, 한 모듈의 변경이 다른 모듈에 미치는 영향을 최소화한다.

 

그래서 `좋은 추상화`란 코드 뒤에 숨은 의도를 파악하고, 쓰임새에 맞도록 적절한 단위와 형태를 만들어 나가는 과정이다. 의미를 잘 드러낸 코드는 읽기도, 수정하기도 좋은 코드가 될 수 있다.

 

이를 잘 이해하게 된다면, `데이터 영역`과 `보여주는 영역` 그리고 둘 사이에 `가공 로직`을 담당하는 무언가가 필요하다고 느낄 것이다.

 

이론은 알겠다. 그렇다면 어떻게 추상화를 해야 한다는 것일까?

예시 코드로 조금이나마 이해를 해보려고 노력해보자.

// 수량 필드명이 함수에 하드코딩 되어있음
function incrementQuantity(item: Item) {
  const quantity = item.quantity;
  const newQuantity = quantity + 1; // 계산 (더하기)
  const newItem = objectSet(item, 'quantity', newQuantity); // 계산 (객체에 반영)
  return newItem;
}

// 크기 필드명이 함수에 하드코딩 되어있음
function incrementSize(item: Item) {
  const size = item.size;
  const newSize = size + 1; // 계산 (더하기)
  const newItem = objectSet(item, 'size', newSize); // 계산 (객체에 반영)
  return newItem;
}

 

위 두 함수를 잘 보면, 암묵적으로 인자가 들어가 있다. 암묵적 인자를 드러내기 위해서 수정이 필요할 것이다.

 

function incrementField(item: Item, field: string) {
  const value = item[field];
  const newValue = value + 1; // 계산 (더하기)
  const newItem = objectSet(item, field, newValue); // 계산 (객체에 반영)
  return newItem;
}

 

지금은 `계산`에서 더하기 기능만 있다. 그런데 만약, 곱하기나 빼기, 나누기 등 연산 수행을 하는 함수를 만든다고 한다면?

연산은 모두 업데이트를 하기 위한 목적을 가지고 있다. 값을 업데이트하기 위한 목적의 `update` 함수를 생성해보면 어떨까?

 

function incrementField(item: Item, field: string) {
  const newItem = updateField(item, field, function (value) {
    return value + 1;
  });
	return newItem
}

function updateField(item: Item, field: string, modify: (value: any) => any) {
  const value = item[field];
  const newValue = modify(value);
  const newItem = objectSet(item, field, newValue); // objectSet은 카피 온 라이트를 수행함
  return newItem;
}

 

인자로 객체를 받고, 어떤 키 값에 접근할 지 단서를 함께 제공하면 원하는 인터페이스를 구성할 수 있을 것이다.

 

함수 내용을 콜백으로 바꾸는 리팩토링을 하여 `updateField` 함수에 콜백을 전달하고, 제어권을 전달받은 본래 함수는 콜백을 호출하도록 하였다.

 

다시 보니, 객체를 변경한다는 추상적인 동작을 하나의 단위로 묶을 수 있을 것이다. 이를 `update` 함수로 분리한다면?

구체적이지 않고, 매우 일반적인 동작을 지원하는 함수로 말이다.

 

function update(
  object: Record<string, any>,
  key: string,
  modify: (value: any) => any
) {
  const value = object[key];
  const newValue = modify(value);
  const newObject = objectSet(item, key, newValue);
  return newObject;
}

 

`update` 함수는 객체를 다루는 함수형 도구로, 값 하나를 인자로 받아 객체에 적용한다. 하나의 키에 하나의 값을 변경하도록 말이다. 따라서 `객체`, `변경할 값이 어디 있는지 알려주는 키`, `값을 변경하는 동작이 서술된 함수`를 구현하면 될 것이다.

 

그래서 리팩토링하여 추상화한 코드를 보자면, 아래의 코드처럼 수정이 가능할 것이다.

`incrementField` 함수는 값을 증가한다는 것만 나타내고, 직접적으로 나타날 필요 없는 `계산`을 하는 동작은 `update` 함수에 전달하여 값을 받아오도록 한 것이다.

 

function incrementField(item: Item, field: string) {
  return update(item, field, function (value) {
    return value + 1;
  });
}

 

이렇게, 추상화(캡슐화)를 하지 않는다면 UI 컴포넌트에 로직을 포함시킨다면 컴포넌트는 복잡해질 것이다. 그러니, 추상화에 대해 조금만 더 생각해보고, 지금 내 코드에서 "이 동작이 지금 컴포넌트에서 실행되도록 하는 게 맞을까?" 고민해보는 습관을 들일 필요가 있다고 생각한다.

 

그리고 한 가지 더!

"가끔 클래스로 구현하는 것과 함수로 구현하는 것 중 무엇이 나을까?",  "강의를 들으면 클래스로 설명해줄까?" 라는 생각을 종종 했었다. 나는 클래스를 잘 사용하고, 함수로 구현하는 것보다 더 낫다고 생각을 했는데 한 멘토님께서 "무엇을 사용하는지 상관없다. 그것은 중요하지 않다"고 하신 말씀으로 불필요한 생각이라는 것을 깨달았다.

 

아래의 코드처럼 말이다.

 

// User라는 객체 → id, name, 구매횟수, 총 구매금액, 구매 내역
// 만약, 어떤 유저가 → 10회 이상 구매를 할경우 ‘우수회원’, 100회 이상 구매를 할 경우 ‘VIP’라고 정한다
// 인삿말
//  - 일반회원: 환영합니다.
//  - 우수회원: 항상 감사합니다.
//  - VIP: 언제 저희 가게 한번 놀러오시죠 극진히 대접하겠습니다.

// User -> VIP, BestUser

class VIP implements User {
	get greeeting {
		return '언제 저희 가게 한번 놀러오시죠 극진히 대접하겠습니다'
	}
}

const createVipUser = (user: User) => {
  return {
    greeting: '언제 저희 가게 한번 놀러오시죠 극진히 대접하겠습니다'
  }
}


const Dashboard1 = () => {
  const [users] = useState<User[]>([])

  const vips = users.filter(user => user.purchaseCount >= 105)
  const goodUsers = users.filter(user => user.purchaseCount >= 10)
  
  return <div>...</div>
}
// ...
const Dashboard100 => {}

 

클래스로 구현하든, 함수로 구현하든 사실 똑같이 동작한다. 의미가 크지 않다는 것이다.

그러니, 둘 중 무엇으로 구현해야 할 지 고민하기 보다 적절한 캡슐화에 고민하는 시간을 더 갖자.