Published on

프론트엔드에서 개발 설계하는 방법

Authors
  • avatar
    Name
    piano cat
    Twitter

원티드 프리온보딩 12월 수업을 들으면서 앞으로 개발을 어떻게 해야할지 에 대해서 많은 도움이 되었다. 아래는 내가 이해한 내용 대로 정리를 해보았다.

수학의 수식, 물리학의 공식, 논리학의 논리 등은 그 자체로 하나의 언어이다. 이 언어는 우주의 근본적인 진리를 설명하고, 복잡한 현상을 단순하고 우아한 방식으로 표현할 수 있는 능력을 가지고 있다. 많은 사람들은 이 지점에서 아름다움을 느낀다.

개발을 할때도 마찬가지다. 나만의 개발 방법론이 있다면 어떠한 문제가 주어져도 이상적이고 단순한 방식으로 해결할 수 있다면 그건 얼마나 아름다운가. 매번 문제상황이 부딪힐때마다 주먹구구식으로 개발하는 것보단 정확히 어떻게 해야할지 알고 있다면 개발을 더욱 재밌고 쉬워질 것이다.

'개발을 잘하는 방법은 개발을 안하는 것이다.'

이말은 왠지 말장난 같지만 나름 이유가 있다. 개발을 하면 할 수록 내가 작성한 코드들은 부채가 되어 나중에 고쳐야 될수 있는 빛처럼 쌓이게 된다. 그렇기에 개발자는 자신의 코드를 유지보수하기 쉽도록 작성하려고 노력한다. 결국 개발자는 일을 줄이는 일에 많은 노력을 들여야한다. 불필요한 일을 줄이는 것도 중요하지만 단순히 일의 양을 줄이는 것이 해결책은 아니다. 유지보수에 용이하며, 효율적으로 시간을 사용할수 있도록 개발해야한다.

  • 데이터, 계산, 액션 구분하기
  • 비즈니스 로직 구분하기
  • 부적절하게 구분된 컴포넌트 구별하기

위 조건들을 충족시킬 수 있는 코드를 작성하는 게 목표이다. 추상화와 리팩토링, 함수 분리 등으로 위의 방법론을 익혀보자

개발은 정답이 없다 하지만 핵심적인 원리들은 잘 정립되어 있다.

SOLID, 클린아키텍처, 클린 코드 등에서 나타나는 기법들은 업계의 표준화된 레퍼런스로써 즉 판단의 근거가 될 수 있다. 즉 이론을 익히고, 실무에 적절히 사용할 수 있도록 단련해야한다.

둘이서 협력하면서 작업하면 서로 시각이 다르기 때문에 두 사람의 다른 시각을 연결해 줄 다리가 필요하고, 그 다리에는 필연적으로 추상화의 요소가 있게 됩니다. 서로 다른 것들을 하나로 묶어야 하기 때문입니다. 반면 혼자서 작업할 경우에는 이런 추상화의 필요가 덜합니다.

모두가 납득할수 있는 추상화 작업은 단순하지 않다. 하지만 그러한 추상화작업이 완성된다면, 어디서든지 쓸 수 있는 방법중 하나가 될 것 같다.

데이터, 계산, 액션 구분하기

function ExampleComponent() {
  // ...
}

async function fetchData(setData, navigate) {
  const response = await fetch('<https://some-api.com/data>')
  const result = await response.json()
  setData(result)

  if (result.needsRedirect) {
    navigate('/redirect-page')
  }
}

위 처럼 fetchData 를 분리한 컴포넌트는 분리가 잘되었을까

우선 fetchData 함수의 목적 이외에도 setData, navigate 기능 등이 추가로 포함되어있다. 이는 setData 와 navigate 함수를 의존성으로 가지며, fetchData 함수만으로 예상할 수 있는 동작을 벗어났기때문에 함수의 기능을 파악하기가 더 어려워졌다.

Pure Function 과 Side Effect 의 차이점

Pure Function 은 입력값에 따라 항상 동일한 출력값을 얻는 함수라고 할수 있다.

const add = (a, b) => a + b

Side Effect는 함수의 핵심목적에서 벗어나 외부 세계에 영향을 주는 행위가 포함된 함수이다.

const asyncCall = async () => {
  return fetch(/** 비동기 호출 */)
}

데이터, 계산, 액션이란?

1) 데이터 : 이벤트에 대한 사실. 문자열, 객체 등 단순한 값 그 자체. 2) 계산 : 입력으로 얻은 출력. 순수 함수, 수학 함수 라고 부르기도 함. 3) 액션 : 외부 세계와 소통하므로 실행 시점과 횟수에 의존. 부수 효과를 일으킴.

데이터와 계산은 순수함수 영역이며, 액션은 사이드이펙트 영역이라고 볼수 있다. 되도록 데이터와 계산으로부터 액션을 분리해내는 식으로 구현을 하는것이 좋다. 리액트의 함수형 컴포넌트를 예로 들어보면 함수형 컴포넌트는 순수함수형태를 추구한다. 하지만 동적인 이펙트를 구현할수 있도록 useEffect 라는 hook을 사용해 사이드 이펙트를 처리할수 있도록 한다.

데이터, 계산, 액션을 구분하여 분리하면 어떤 도움이 될까

리액트로 예를 들어보면 데이터와 계산으로 부터 액션을 구분하여, 계산 로직들을 컴포넌트 바깥으로 추출하여 추상화 할수 있다!

비즈니스 로직 구분하기

비즈니스 로직을 왜 구분해야할까

  1. 비즈니스 로직이 외부로 노출될경우 위험하다. 예를 들어 할인 기능과 사용자 인증 등 클라이언트에 노출된다면 클라이언트에서 조작하는 등 보안상 위험한 상황이 일어날수 있기때문이다.
  2. 클라이언트에선 화면을 보여주는 기능등을 주로 구현해야하지만, 비즈니스로직이 포함된다면 복잡한 컴포넌트를 구성하게 되고 이는 관리 하기 어려운 상황이 발생할 수 있다.

비즈니스로직과 UI 로직 구분하기

액션을 일으키는 모든 로직이 비즈니스 로직은 아니다.

비즈니스 로직예시

  1. 가격 할인로직
  2. 재고 관리
  3. 사용자 인증

UI 기능 로직

  1. 버튼 클릭
  2. 스크롤 기능
  3. 폼 제출

추상화를 통해 비즈니스 로직을 격리 하자

추상화 작업을 통해 모듈을 만들고 응집도는 높게, 결합도는 낮게 만드는 만들어질 수 있도록 노력하자 계산의 기능들을 추상화 작업을 통해 정리하는 습관을 들이자, 계산을 캡슐화 한다고 생각하고 코드를 리팩토링한다 추가로 은닉화 까지 해주면 좋다.

의존성은 추상화 벽을 통해 해결하자

각 모듈은 어떠한 요소에 의존성을 띄고 있다면 그 요소가 수정되거나 사라졌을경우 변경해야될 사항이 생긴다. 의존성은 모듈의 인터페이스 역할을 한다. 모듈의 인터페이스를 잘 이해하고 의존성이 변경되더라도 인터페이스의 벽을 새로 생성하여 문제를 해결할 수 있다.

SOILD 원칙

SOLID SRP : 단일 책임 원칙 OCP: 개방 폐쇄 원칙 LSP: 리스코프 치환 원칙 ISP: 인터페이스 분리 원칙 DIP: 의존성 역전 원칙

추상화, 비즈니스로직 그래서 어떻게 해야할까, SOLID 원칙은 객체 지향 설계의 원칙들이지만 사실 클래스를 사용하는 객체 에서만 사용될 이유는 없다. 객체와 데이터를 사용하는 자바스크립트에서도 충분히 사용될 수 있다.

'응집된(cohesive)'이라는 단어가 SRP를 암시한다. 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성(cohesion)이다.

SRP는 하나의 모듈은 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다.

OCP는 “확장에는 열려 있고, 수정에는 닫혀 있도록 한다” 는 문장으로 정리될 수 있습니다. 기존 코드를 수정하기 보다는 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경하기 쉽도록 설계하라는 건데요. 우리가 알고 있는 개념 중에 가장 이 원칙에 부합하는 걸 꼽으라면 단연 컴파운드 컴포넌트 패턴(CCP) 을 꼽겠습니다.

<Card>
  <Card.Title>foo</Card.Title>
  <Card.Content>bar</Card.Content>
  {user ? (
    <Card.CommentsWithAuth comments={comments} />
  ) : (
    <Card.CommentsWithAnonymous comments={comments} />
  )}
</Card>
  • SRP: Card 이하 각각의 서브 컴포넌트들이 명확한 책임을 가지도록 분리되어, 수정의 범위와 시점이 이전에 비해 구체적일 수 있도록 개선되었습니다.
  • OCP: Card 에 기능을 추가하거나 변경하고 싶을 때 기존 코드를 전혀 건드리지 않고도 새로운 컴포넌트를 추가함으로써 기능을 수정하고 확장할 수 있는 설계가 되었습니다.

LSP는 하위 클래스가 상위 클래스를 대체할 수 있어야 한다는 원칙입니다. 즉, 내부 구현이 변경되더라도 외부 인터페이스와 행동은 변하지 않아야 합니다.

LSP 의 특징으로서는

  • 대체 가능성: 하위 클래스의 인스턴스는 프로그램의 정확성을 해치지 않으면서 상위 클래스의 인스턴스를 대체할 수 있어야 합니다.
  • 계약에 의한 설계: 하위 클래스는 상위 클래스가 정의한 계약(ex. 메소드 사양)을 준수해야 합니다.
  • 행동 호환성: 하위 클래스는 상위 클래스의 행동(ex. 메소드가 반환하는 값의 범위, 예외 처리)을 보존해야 합니다.

의존성을 가진 컴포넌트가 있다고 생각하고, 그 의존성이 변경되더라도 기존의 컴포넌트는 올바르게 작동되도록 설계해야 한다. 이렇게 동작하기 위해선 의존하고 있는 인풋요소와 영향을 주는 아웃풋요소는 동일하게 유지 되어야한다.

ISP의 특징은 사용하지않는 것에 의존하지 않아야 한다는 것이다.

SRP가 클래스의 단일 책임 원칙이라면, ISP는 인터페이스의 단일 책임 원칙이다 이 원칙을 잘 지키기 위해선 최대한 인터페이스를 잘 분리하는 것이 중요하다. 허나 한번 설계된 인터페이스는 나중에 추가로 분리하는 경우는 되도록 안하는 것이 좋다. 왜냐하면 기존의 설계에 문제가 생길 위험성이 있기 때문이다.

DIP 는 의존성 역전이란 ‘소스코드의 의존성이 제어흐름과는 반대 방향으로 역전되는 것’을 뜻합니다. 쉽게 이야기 하면 필요한 정보를 내부에서 구체적으로 정의하지 말고, 외부에서 추상의 형태로 주입받아 쓰라는 뜻입니다. 이렇게 하면 클래스가 의존하는 외부 요소가 수정되거나 변경되어도 해당 클래스는 수정할 필요가 없게 됩니다.

예를 들면, 유저로그인서비스를 클래스로 정의한다고 할때 로그인 로직들을 로그인서비스 클래스 내부에 정의하지않고 추상의 형태로된 인터페이스를 외부에서 주입받아서 사용할 수 있도록 설계하는 것이다.

// 사용자 인스턴스 생성을 위한 의존성 주입
const userService = new UserService(new MysqlUserAdaptor(), new JWTTokenAdaptor())

// 로컬 로그인 함수 사용 예
async function loginUser(email, password) {
  try {
    const token = await userService.localLogin(email, password)
    console.log('User logged in with token:', token)
    // 로그인 성공 시, 토큰을 사용하는 로직
  } catch (error) {
    console.error(error.message)
    // 에러 처리 로직
  }
}

// 사용자 로그인 시도
loginUser('user@example.com', 'password123')

IOC(Inversion Of Control)

IOC는 기능(함수 등)의 제어권을 외부로 위임하는 것 을 의미한다.

예를 들면 계산과 액션을 컴포넌트 내부에서 정의하지않고 외부에 작성하고 컴포넌트에 의존성으로 전달하는 방식이라고 볼수 있다. 이렇게 하면 코드의 결합도를 낮출수 있는 장점이 있다.

의존성을 어떻게 전달해주는지에 대한 방법으로는 여러 방식이 있을 것 같다. prop으로 전달해주는 방법, 코드 상단에 import 선언으로 전달하는 방법, provider 패턴으로 context API를 활용한 전달 방법이 있을수도 있다. 다양한 방법이 있지만 각각 어떻게 활용해야할지는 사용하면서 익혀야 할 부분인 것 같다.

일단은 hook 으로 설계된 액션은 import 선언으로, 그외는 prop으로 설정하는게 좋아 보인다. prop으로 의존성을 전달하면 해당 컴포넌트가 어떤 의존성을 가지는지 쉽게 알수 있는 장점이 있으나 단점으로는 prop 작성이 많아질수도 있는 점이다. (그럴땐 provider 패턴을 고려해보자)

내부 컴포넌트에서 코드를 작성할때 인터페이스를 미리 작성하고 외부에서 추상화된 모듈을 전달 할거라 생각하고 개발하자

도메인 먼저 설계 구현은 나중에

상태 관리가 모든 이익(revenue)의 근원이다. 그래서 겸허히 말씀드리건대 우리는 웹 애플리케이션을 다른 방향에서부터 만들어야 하고, 먼저 사용자가 시스템과 어떻게 상호작용할지 코드로 작성하는 일부터 시작해야 합니다. 앱을 사용하면서 어떤 과정을 거치게 되는지, 또 어떤 정보를 필요로 할지, 어떤 정보를 서버로 전송할지 등 달리 말하면 해결해야 하는 문제의 도메인(domain)을 모델링하는 작업부터 시작하는 겁니다. 이런 문제를 해결하는데 코드를 작성하는 일은 굳이 UI 라이브러리를 사용하지 않고서도 할 수 있습니다. 비지니스 로직을 수행하는데 필요한 동작을 추상적인 형태로 만들 수 있습니다.

React 는 UI 렌더러 이다.

그럼 React에서 어떻게 해야할까

리액트 개발 :  UI를 먼저 개발한다. 이후 이벤트 리스너, API 호출 등에 대한 로직을 작성하고 View와 연결한다.

만약 우리가 이 흐름을 뒤집어 API에 대한 로직부터 먼저 작성해본다면 어떻게 달라질까요? 정확히는 API 호출 로직을 먼저 짜는 것이 아니라 1) 비즈니스에 필요한 기본적인 요소들을 먼저 정의하고 2) 그 요소들이 행동할 수 있는 케이스를 수집하여 기능을 구현한 뒤 3) 마지막에 UI를 작성하여 기능과 연동하는 방법 말이죠.

위 순서대로 작업한다면 비즈니스 로직에 대한 SSOT, 빠른온보딩, 쉬운 테스팅이 가능해진다.

즉, 도메인 부터 설계해보자

도메인 모델링은 어떻게 해야할까

애플리케이션의 (비지니스)로직을 다양한 UI에서 사용될 수 있도록 설계하고 개발하세요. 그럴 일이 없다 하더라도요. 메인스트림 프레임워크(리액트, 앵귤러, 뷰 등)가 의도치 않게 로직과 표현 계층이 결합되도록 유도하고 있는데, 이를 피할 수 있도록 강제합니다. 이 시점에서 아직 사용자가 어떤 환경의 도구를 사용하는지는 관계없이 비지니스와 상호작용을 할 수 있습니다. 웹 애플리케이션일까요? 리액트 네이티브 애플리케이션일까요? npm 모듈의 SDK일까요? 아니면 CLI(Command Line Interface)? 아무런 상관 없습니다! 따라서- 먼저, 웹 애플리케이션이 아니라 CLI를 만들듯이 상태와 스토어, 프로세스를 설계하세요.

비지니스 프로세스를 함수 형태로 직접 호출하는 것 만큼 단순한게 없습니다.

리액트는 저에게 CLI나 같습니다. 사용자 입력을 받아서 프로세스를 실행하고, 그 데이터를 멋진 결과물로 보여주는 일을 합니다. 리액트는 사용자 인터페이스를 만들기 위한 라이브러리 입니다. 비지니스 프로세스를 다루는 라이브러리가 아닙니다.

React에서의 예시

LoginPage 를 구현하다고 예를 들어보자 자세한 추상화는 없지만 일단 이러한 구조로 도메인 모델을 구상해보았다.

image

 // 인증 서비스 인터페이스
interface AuthService {
  signin(credentials: any): Promise<User>;
  signout(): Promise<void>;
  signup(): Promise<user>;
  getCurrentUser(): User | null;
}

class CredentialsAuthService implements AuthService {
	signin(credentials: any) : Promise<User> {
		// 로그인 로직 구현
	}
	signout(): Promise<void> {
		// 로그아웃 로직 구현
	}
	signup(): Promise<user> {
		// 회원가입 로직 구현
	}
	getCurrentUser(): User | null {
		// 로그아웃 로직 구현
	}
}
class SocialAuthService implements AuthService {
	signin() : Promise<User> {
		// 외부 oAuth 로직 호출
	}
	signout(): Promise<void> {
		// 로그아웃 로직 구현
	}
	signup(): Promise<user> {
		// 회원가입 로직 구현
	}
	getCurrentUser(): User | null {
		// 로그아웃 로직 구현
	}
}
	// 의존성 주입
	const credentialsAuthService = new CredentialsAuthService()

    const LoginPage = () => {
      return (
        <input />
        <input />
        <button onClick={() => credentialsAuthService.singin(credentials)}>login button</button>
        <button onClick={() => SocialAuthService.singin()}>Social Login button</button>
        <checkbox>Remember me option</checkbox>
        <ErrorMessage />
      );
    };

정리

다시 처음으로 돌아가서 일을 잘하기 위해, 효율적으로 코딩하는 방법론을 익혀야 하고, 이 방법론이란 데이터, 계산, 액션을 분리하여 모듈화 작업을 해야하며, 비즈니스 로직을 분리하여 UI 영역과 분리해야하고, 추상화, SOLID, IOC 원칙을 이해한 바탕으로 컴포넌트 설계 그리고 개발을 어떻게 시작하고 관리하고 운영해 나갈지에 대해 이해할 수 있었다. 아직 도메인 모델을 설계하고 추상화, DDD 구분 등을 하는데 익숙하지 않지만 여러번의 연습과정을 거치면 좀더 효과적인 설계를 할수 있을거라 생각 된다.