React-redux & 제네릭에 대해

Hyune·2021년 3월 5일
5
post-thumbnail

소개


redux는 Javascript 어플레케이션에서 data-state와 UI-state를 관리해주는 도구입니다. 이는 상태적 데이터 관리가 시간에 따라 복잡해질 수 있는 싱글어플리케이션(Single Page Application)에서 매우 유용하게 사용됩니다. 또한 Redux는 React 외에도, jQurey, Angular 등을 사용하는 어플리케이션에서도 사용할 수 있습니다.

React 에선 데이터의 흐름이 단일 방향으로만 흐릅니다. 또한 parent-child 관계를 통하여 데이터를 교류할 수 있습니다. 하지만, 컴포넌트의 갯수가 많아진다면.. 혹은 데이터를 교류할 컴포넌트들이 parent-child 관계가 아니라면 복잡해질 수 있습니다. 자칫 잘못하다간 스파게티 코드가 만들어 질 위험도 있습니다.

#MVC 디자인 패턴


MVC 패턴은 controller, model, view 이 3가지 개념으로 이뤄져있습니다. 어떠한 Action이 입력되면 Controller은 Model이 지니고 있는 데이터를 조회하거나 업데이트 하며, 이 변화는 View 에 반영되는 구조입니다. 또한, View에서 Model의 데이터에 접근 할 수도 있습니다.

이 구조는 작은 어플리케이션에서는 큰 문제없이 작동합니다. BUT, Model과 View가 늘어난다면 어떻게 될까요? 어떤 모델이 뷰를 건들이고... 무한반복.. 이런 코드를 본다면 분명 머리가 터질 것입니다.

#FLUX 패턴


위 문제를 해결하기 위해서 FLUX 라는 디자인 패턴이 만들어졌습니다. 시스템에서 어떠한 Action을 받았을 때, Dispatcher가 받은 Action들을 통제하여 Store에 있는 데이터를 업데이트합니다. 그리고 변동된 데이트가 있으면 View에 리렌더링합니다.
그리고, View에서 Dispatcher로 Action을 보낼 수도 있습니다.
Dispatche은 작업이 중첩되지 않도록 해줍니다. 즉, 어떤 Action이 Dispatcher를 통하여 Store에 있는 데이터를 처리하고, 그 작업이 끝날 때 까지 다른 Action들을 대기시킵니다.

Redux - 1


다시 본론으로 돌아와서, Redux는, 위에서 설명된 Flux 아키텍쳐를 좀 더 편하게 사용 할 수 있도록 해주는 라이브러리입니다. 이 라이브러리를 사용하면 데이터 관리를 다음과같이 할 수 있게됩니다.

Redux는 store에서 모든 데이터를 담고 있고, 컴포넌트끼리는 직접 교류하지 않고 store 중간자를 통하여 교류합니다.

dispatch와 subscribe는 store에서 사용하는 메소드명이기도 합니다.

Redux의 3가지 원칙

# 하나, Single Source of Truth


Redux는 어플리케이션의 state를 위해 단 한개의 store를 사용합니다. 모든 state가 한곳에 있기 때문에 이를 Single Source of Truth 라고 부릅니다.

store의 데이터 구조는 개발자 하기 나름입니다. 보통 매우 중첩된 구조로 이뤄져있습니다. 즉, JavaScript 객체로서, {{{}{}{}},{}} 이런식으로 잘 정리되어있다는 의미입니다.

# 둘, State is read-only


Redux 매뉴얼을 보면, “The only way to mutate the state is to emit an action, an object describing what happened.” 라고 적혀있습니다. 즉, 어플리케이션에서 state를 직접 변경 할 수는 없다는 의미입니다. state를 변경하기 위해서는, action이 dispatch되어야 합니다. action은, 어떤 변화가 일어나야 할 지 알려주는 객체입니다.

# 셋, Changes are made with Pure Functions


두번째 원칙에 설명된것처럼 Redux에선 어플리케이션에서 state를 직접 변경한다는 것을 허용하지 않습니다. 그 대신에 action을 dispatch하여 상태값을 변경한다고 했죠? 이 과정에서 받아온 action 객체를 처리하는 함수를 Reducer라고 부릅니다. action은 어떤 변화를 어떻게 바꿀지 정의한다고 볼 수 있습니다.

-중간 점검-

store: React.js 프로젝트에서 사용하는 모든 동적 데이터들을 담아두는 곳.

action: 어떤 변화가 일어나야 할 지 나타내는 객체.

reducer: action 객체를 받았을 때, 데이터를 어떻게 바꿀지 처리할지 정의하는 객체.

Redux - 2


import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';

redux를 사용하기 위해선 createStore 객체를 불러와야합니다.

const INCREASE = "counter/INCREMENT";

const DECREASE = "counter/DECREASE";

가장 먼저 해야 할 작업은 액션의 타입을 지정해주는 것입니다. 액션의 타입은 대문자로 정의하고, 문자열 내용은 '모듈 이름 / 액션 이름'과 같은 형태로 작성합니다.

문자열 안에 모듈 이름을 넣음으로써, 나중에 프로젝트가 커졌을 때 액션의 이름이 충돌되지 않게 해 줍니다. 예를 들어, SHOW 혹은 INITIALIZE라는 이름을 가진 액션은 쉽게 중복될 수 있습니다. 하지만 앞에 모듈 이름을 붙여 주면 액션 이름이 겹치는 것을 걱정하지 않아도 됩니다.

const INCREASE = "counter/INCREMENT";

const DECREASE = "counter/DECREASE";

export const increase = () => {( type: INCREASE )};
export const decrease = () => {( type: DECREASE )};

액션 생성 함수를 만들 땐 더 추가할 필요 없이 위와 같이 만들어 주면 됩니다. 여기서 주의해야 할 점은 앞부분에 export라는 키워드가 들어간다는 것입니다. 이렇게 함으로써 추후 이 함수를 다른 파일에서 불러와 사용할 수 있습니다.

const INCREASE = "counter/INCREMENT";

const DECREASE = "counter/DECREASE";

export const increase = () => {( type: INCREASE )};
export const decrease = () => {( type: DECREASE )};

const initialState = {
	number: 0
};

function counter(state = initialState, action) {
	switch (action.type) {
    	case INCREASE:
          return {
          	number: state.number + 1
          };
        case DECREASE: 
          return {
          	number: state.number - 1;
          };
        default: 
          return state;
    }
}


export default counter;

counter 모듈의 초기 상태와 리듀서 함수를 만들어 줍니다. 이 모듈의 초기 상태에는 number 값을 설정해 주었으며, 리듀서 함수에는 현재 상태를 참조하여 새로운 객체를 생성해서 반환하는 코드를 작성해 주었습니다.

마지막으로 export default 키워드를 사용하여 함수를 내보내 주었습니다.

조금 전에 만든 액션 생성 함수는 export로 내보내 주었고, 이번에 만든 리듀서는 export default로 내보내 주었습니다. 두 방식의 차이점은 export는 여러 개를 내보낼 수 있지만 export default는 단 한 개만 내보낼 수 있다는 것입니다.

	import counter from './counter';
    import { increase, decrease } from './counter';
    
    // 한꺼번에 불러오고 싶을 때 
    import counter, { increase, decrease } from './counter';

위와 같이 불러오는 방식도 다릅니다.

제네릭(Generic)


제네릭은 어떠한 클래스 혹은 함수에서 사용할 타입을 그 함수나 클래스를 사용할 때 결정하는 프로그래밍 기법이다.

JAVA, C++ 등의 정적 타입 언어에선 함수 및 클래스를 선언하는 시점에서 매개변수 혹은 리턴 타입을 정의해야하기 때문에 기본적으로는 특정 타입을 위해 만들어진 클래스나 함수를 다른 타입을 위해 재사용 할 수가 없다.

JavaScript는 원래 타입 선언이 필요하지 않고, 그렇기에 특정 타입을 위해 만들어진 클래스나 함수도 타입 에러를 런타임에서 일으킬 뿐이다. 코드를 실행시키기 전까지는 함수와 클래스가 모든 타입에 대응한다. 그렇기 때문에 JavaScript에서는 제네릭이란 말을 들을 일이 없다.

제네릭의 한 줄 정의와 예시


function getText(text) {
	return text;
}

위 함수는 text라는 파라미터에 값을 넘겨 받아 text를 반환해줍니다. hi, 10, true 등 어떤 값이 들어가더라도 그대로 반환합니다.

getText('h1'); // 'hi'
getText(10); // 10
get(true); // true

이 관점에서 제네릭을 한번 살펴보면,

function getText<T>(text: T):T {
	return text;
}

위 함수는 제네릭 기본 문법이 적용된 형태입니다. 이제 함수를 호출할 때 아래와 같이 함수 안에서 사용할 타입을 넘겨 줄 수 있습니다.

getText<string>('hi');
getText<number>(10);
get<boolean>(true);

위 코드 중

getText<string>('hi')

를 호출 했을 때 함수에서 제네릭이 어떻게 동작하는지 살펴보겠습니다.

먼저 위 함수에서 제네릭 타입이 string이 되는 이유는 getText()함수를 호출할 때 제네릭(함수에서 사용할 타입) 값으로 string을 넘겼기 때문입니다.

getText<string>();

그리고 나서 함수의 인자로 hi라는 값을 아래와 같이 넘기게 되면

getText<string>('hi');

getText 함수는 아래와 같이 타입을 정의한 것과 같습니다.

		retrun text; 
} 

위 함수는 입력 값의 타입이 string 이면서 반환 값 타입도 string 이어야 합니다.

제네릭을 사용하는 이유


	function logText(text: string): string {
    	return text;
    }

위 코드는 인자를 하나 넘겨 받아 반환해주는 함수입니다. 여기서 이 함수의 인자와 반환 값은 모두 string으로 지정되어 있지만 만약 여러 가지 타입을 허용하고 싶다면 아래와 같이 any를 사용할 수 있습니다.

	function logText(text: any):any {
    	return text;
    }

이렇게 타입을 바꾼다고 해서 함수의 동작에 문제가 생기진 않습니다. 다만, 함수의 인자로 어떤 타입이 들어갔고 어떤 값이 반환되는지는 알 수가 없습니다.왜냐하면 any라는 타입은 타입 검사를 하지 않기 때문입니다.

이러한 문제를 해결할 수 있는 것이 바로 제네릭입니다.

	function logText<T>(text: T): T {
    	return text;
    }

먼저 함수의 이름 바로 뒤에 <'T'>라는 코드를 추가했습니다. 그리고 함수의 인자와 반환 값에 모두 T라는 타입을 추가합니다. 이렇게 되면 함수를 호출할 때 넘긴 타입에 대해 타입스크립트가 추정할 수 있게 됩니다. 따라서, 함수의 입력 값에 대한 타입과 출력 값에 대한 타입이 동일한지 검증할 수 있게 됩니다.

또한, 이렇게 선언한 함수는 아래와 같이 2가지 방법을 통해 호출 할 수 있습니다.

	// #1
    const text = logText<string>("Hello Generic");
    
    // #2
    const text = logText("Hello Generic");

보통 두 번째 방법이 코드도 더 짧고 가독성이 좋기 때문에 흔하게 사용됩니다. 그렇지만 만약 복잡한 코드에서 두 번째 코드로 타입 추정이 되지 않는다면 첫 번째 방법을 사용하면 됩니다.

제네릭 타입 변수


앞에서 배운 내용으로 제네릭을 사용하기 시작하면 컴파일러에서 인자에 타입을 넣어달라는 경고를 보게 됩니다.

조금 전에 살펴본 코드를 다시 보겠습니다.

	function logText<T>(text: T): T {
    	return text;
    }

Q. 만약 여기서 함수의 인자로 받은 값의 length를 확인하고 싶다면 어떻게 해야 할까요?

A. 아마 아래와 같이 코드를 작성 할 겁니다.

	function logText<T>(text: T): T {
    	console.log(text.length); // Error: T doesn't have .length
        return text;
    }

위 코드를 변환하려고 하면 컴파일러에서 에러를 발생시킵니다. 왜냐하면 text에 .length가 있다는 단서는 어디에도 없기 때문이죠.

다시 위 제네릭 코드의 의미를 살펴보면 함수의 인자와 반환 값에 대한 타입을 정하진 않았지만, 입력 값으로 어떤 타입이 들어왔고 반환 값으로 어떤 타입이 나가는지 알 수 있습니다. 따라서, 함수의 인자와 반환 값 타입에 마치 any를 지정한 것과 같은 동작을 한다는 것을 알 수 있죠. 그래서 설령 인자에 number타입을 넘기더라도 에러가 나진 않습니다. 이러한 특성 때문에 현재 인자인 text에 문자열이나 배열이 들어와도 아직은 컴파일러 입장에서 .length를 허용할 순 없습니다. 왜냐하면 number가 들어왔을 땐 .length 코드가 유효하지 않기 때문입니다.

그래서 이런 경우에는 아래와 같이 제네릭에 타입을 줄 수가 있습니다.

	function logText<T>(text: T[]): T[] {
    	console.log(text.length); // 제네릭 타입이 배열이기 때문에 'length'를 허용합니다.
        return text;
    }

위 코드가 기존의 제네릭 코드와 다른 점은 인자의 T[] 부분입니다. 이 제네릭 함수 코드는 일단 T라는 변수 타입을 받고, 인자 값으로는 배열 형태의 T를 받습니다. 예를 들면, 함수에 [1,2,3]처럼 숫자로 이뤄진 배열을 받으면 반환 값으로 number를 돌려주는 것입니다. 이런 방식으로 제네릭을 사용하면 꽤 유연한 방식으로 함수의 타입을 정의해줄 수 있습니다.

혹은 다음과 같이 좀 더 명시적으로 제네릭 타입을 선언할 수 있습니다.

	function logText<T>(text: Array<T>): Array<T> {
    	console.log(text.length);
        return text;
    }

제네릭 타입


제네릭 인터페이스에 대해 알아보겠습니다. 아래의 두 코드는 같은 의미입니다.

	function logText<T>(text: T): T {
    	return text;
    }
    // #1
    let str: <T>(text: T) => T = logText;
    // #2
    let str: {<T>(text: T): T} = logText;

위와 같은 변형 방식으로 제네릭 인터페이스 코드를 다음과 같이 작성할 수 있습니다.

	interface GenericLogTextFn {
    	<T>(text: T): T;
    }
    function logText<T>(text: T): T {
    	return text;
    }
    let myString: GenericLogTextFn = logText; // Okay

위 코드에서 만약 인터페이스에 인자 타입을 강조하고 싶다면 아래와 같이 변경할 수 있습니다.

	interface GenericLogTextFn<T> {
  		(text: T): T;
	}
	function logText<T>(text: T): T {
  		return text;
	}
	let myString: GenericLogTextFn<string> = logText;	

이와 같은 방식으로 제네릭 인터페이스 뿐만 아니라 클래스도 생성할 수 있습니다. 다만, 이넘(enum)과 네임스페이스(namespace)는 제네릭으로 생성할 수 없습니다.

제네릭 클래스


제네릭 클래스는 앞에서 살펴본 제네릭 인터페이스와 비슷합니다. 코드를 보면,

class GenericMath<T> {
  pi: T;
  sum: (x: T, y: T) => T;
}

let math = new GenericMath<number>();

제네릭 클래스를 선언할 때 클래스 이름 오른쪽에 <'T'>를 붙여줍니다. 그리고 행당 클래스로 인스턴스를 생성할 때 타입에 어떤 값이 들어갈 지 지정하면 됩니다.

조금 전에 살펴본 인터페이스처럼 제네릭 클래스도 클래스 안에 정의된 속성들이 정해진 타입으로 잘 동작하게 보장할 수 있습니다.

제네릭 제약 조건


제네릭 함수에 어느 정도 타입 힌트를 줄 수 있는 방법이 있습니다. 잠시 이전 코드를 보자면,

	function logText<T>(text: T):T {
    	console.log(text.length); // Error: T doesn't have .length 
        return text;
    }

인자의 타입에 선언한 T는 아직 어떤 타입인지 구체적으로 정의하지 않았기 때문에 length 코드에서 오류가 납니다.

이럴 때 만약 해당 타입을 정의하지 않고도 length 속성 정도는 허용하려면 아래와 같이 작성합니다.

interface LengthWise {
	length: number;
}

function logText<T extends LengthWise>(text: T): T {
	console.log(text.length);
    return text;
}

위와 같이 작성하게 되면 타입에 대한 강제는 아니지만 length에 대해 동작하는 인자만 넘겨받을 수 있게 됩니다.

	logText(10); // Error, 숫자 타입에는 'length'가 존재하지 않으므로 오류 발생
    logText({ length: 0, value: 'hi' }); // 'text.length' 코드는 객체의 속성 접근과 같이 동작하므로 오류 없음

객체의 속성을 제약하는 방법


두 객체를 비교할 때도 제네릭 제약 조건을 사용할 수 있습니다.

	function getProperty<T, 0 extends keyof T>(obj: T, key: 0) {
		return obj[key];    
    }
    let obj = { a: 1, b: 2, c: 3 };
    
    getProperty(obj, "a"); // okay
    getProperty(obj, "z"); // error: "z"는 "a", "b", "c" 속성에 해당하지 않습니다.

제네릭을 선언할 때 <0 extends keyof T> 부분에서 첫 번째 인자로 받는 객체에 없는 속성들은 접근할 수 없게끔 제한하였습니다.


출처

(https://joshua1988.github.io/ts/guide/generics.html#%EC%A0%9C%EB%84%A4%EB%A6%AD-%EC%A0%9C%EC%95%BD-%EC%A1%B0%EA%B1%B4)

(https://velopert.com/1266)

profile
개발자는 개팔자다.

2개의 댓글

comment-user-thumbnail
2021년 10월 14일

우와 ༼ つ ◕_◕ ༽つ

답글 달기
comment-user-thumbnail
2021년 12월 23일

🔥🔥🔥🔥🔥정말 좋은 글이네요`🔥🔥🔥🔥🔥

답글 달기