리액트의 기본 이론적 개념에 대해 알아보자!

윤효준·2024년 8월 9일

React

목록 보기
3/6

아래 링크를 참고하여 작성했다!

https://github.com/reactjs/react-basic

해당 README.md는 sebmarkbage라는 유저에 의해 작성되었고 facebook/react Github의 Pull requests가 main에 merge된 것을 보아 react 팀 사람인 거 같다!

Transformation

React의 핵심 전제는 UI(User Interface)가 데이터를 다른 형태로 변환한 결과라는 것이다.
이 변환 과정은 Pure Function으로 생각할 수 있다.

여기서 잠깐!! Pure Function이란??

순수 함수란 동일한 입력에 동일한 출력을 반환하는 함수이다. 다시 말해 입력값만을 기반으로 결과를 생성한다는 것이고 이는 외부 상태나 부수 효과(side effect)에 의존하지 않는다는 것이다.

외부 상태란 함수 외부에 존재하는, 함수가 직접 접근할 수 있는 데이터나 변수를 의미한다.
아래의 예시는 외부 상태에 의존하는 함수이다.

let externalValue = 10;

function add(x) {
  return x + externalValue;
}

이 함수는 externalValue값이 바뀌면 동일한 입력에 동일한 출력을 가지지 않는다.

부수 효과란 함수가 자신이 받은 입력을 처리하는 것 외에 다른 작업을 수행하는 것을 의미한다. 예를 들자면 외부 상태를 변경하거나 외부와의 상호작용(예: I/O 작업, 상태 변경)을 하는 것이다.

아래의 예시는 부수 효과를 수행하는 함수이다.(콘솔에 로그를 출력하는 것도 부수 효과로 간주)

let count = 0;

function incrementCount(x) {
  count++;
  console.log(`Count is now ${count}`);
  return (x);
}

Abstraction

복잡한 UI를 단일 함수로 처리하는 것이 어렵기 때문에, UI를 재사용 가능한 작은 조각들로 나누어 추상화하는 것이 중요하다. 또한 이러한 추상화된 조각들은 내부 구현 세부사항이 외부로 드러나지 않도록 해야 한다.

두 번째 문장이 이해가 되지 않았는데 예를 들어 설명하자면 sort()함수는 배열을 정렬하는 복잡한 알고리즘을 내부적으로 수행하지만, 사용자는 내부 동작을 몰라도 간단히 sort()를 호출하여 배열을 정렬할 수 있다. 다시 말해 추상화는 불필요한 세부 사항을 숨기고, 중요한 개념이나 기능만을 노출함으로써 복잡성을 줄이는 것이다.

function FancyUserBox(user) {
  return {
    borderStyle: '1px solid blue',
    childContent: [
      'Name: ',
      NameBox(user.firstName + ' ' + user.lastName)
    ]
  };
}

위에서 FancyUserBox 함수는 더 작은 컴포넌트인 NameBox를 호출하여 그 결과를 사용한다.
이는 복잡한 UI를 더 작은 조각으로 나누고 각각의 조각을 재사용 가능한 방식으로 만들 수 있다는 것을 보여준다.

{ firstName: 'Sebastian', lastName: 'Markbåge' } ->
{
  borderStyle: '1px solid blue',
  childContent: [
    'Name: ',
    { fontWeight: 'bold', labelContent: 'Sebastian Markbåge' }
  ]
};

또한 NameBox의 구현 세부사항은 { fontWeight: 'bold', labelContent: 'Sebastian Markbåge' } 와 같지만 FancyUserBox에서 사용할 때는 이를 알 필요가 없다.

Composition

진정한 재사용 가능한 기능을 이루려면 단순히 leaf를 재사용하고 그것들은 새로운 container에 담는 것에 끝나서는 안 된다. 여러 추상화로부터 새로운 추상화를 이끌어 낼 수 있어야 한다.

function FancyBox(children) {
  return {
    borderStyle: '1px solid blue',
    children: children
  };
}

function UserBox(user) {
  return FancyBox([
    'Name: ',
    NameBox(user.firstName + ' ' + user.lastName)
  ]);
}

위의 예시에서 FancyBoxchildren 배열을 입력으로 받고 UserBoxFancyBox함수를 호출하여 사용자 이름을 포함한 상자를 반환한다. 여기서 구성이란 UserBoxFancyBox라는 추상화를 사용하여 더 복잡한 기능을 구현했다는 것이다.

State

UI의 상태는 단순히 서버에서 가져온 데이터나 비즈니스 로직의 결과를 그대로 보여주는 것이 아니다. UI는 자체적인 상태를 가질 수 있고 이 상태는 특정 화면이나 컴포넌트에만 관련될 수 있다. 스크롤 위치로 예를 들자면 스크롤 위치는 사용자가 보고 있는 페이지에만 관련이 있는 상태이고 이는 다른 페이지나 기기와 동기화할 필요가 없다.

function FancyNameBox(user, likes, onClick) {
  return FancyBox([
    'Name: ', NameBox(user.firstName + ' ' + user.lastName),
    'Likes: ', LikeBox(likes),
    LikeButton(onClick)
  ]);
}

// Implementation Details

var likes = 0;
function addOneMoreLike() {
  likes++;
  rerender();
}

// Init

FancyNameBox(
  { firstName: 'Sebastian', lastName: 'Markbåge' },
  likes,
  addOneMoreLike
);

해당 예시는 likes 라는 상태를 변경하는 내용을 가지고 있다. 근데 여기서 addOneMoreLike 함수는 함수 외부의 변수를 직접 변경하기에 순수 함수가 아니다.

또한 우리는 데이터 모델이 불변인 것을 선호한다. 불변 데이터는 한 번 생성되면 수정되지 않고 상태를 변경하려면 새로운 데이터를 생성해야 한다. 이러한 방식은 상태 관리를 더 명확하고 예측 가능하게 한다.

그래서 우려되는 내용을 수정하여 위의 코드를 다시 작성하자면 아래와 같다.

function addOneMoreLike(likes) {
  return likes + 1; // 새로운 상태를 반환
}

function FancyNameBox(user, likes, onClick) {
  return FancyBox([
    'Name: ', NameBox(user.firstName + ' ' + user.lastName),
    'Likes: ', LikeBox(likes),
    LikeButton(onClick)
  ]);
}

// Init
let likes = 0;

FancyNameBox(
  { firstName: 'Sebastian', lastName: 'Markbåge' },
  likes,
  () => { likes = addOneMoreLike(likes); rerender(); }
);

우선 addOneMoreLike는 함수 외부의 변수를 변경하지 않고 입력값을 기반으로 출력을 하는 순수함수이다.

상태 likes는 불변 데이터이다. 여기서 데이터의 불변성에 대해서 이야기하고 가자!
우선 불변성은 데이터가 한 번 생성되면 변경되지 않도록 보장하는 것이다. 조금 더 확실한 예를 위해 배열을 생각해보겠다.

const foo = ["hello"];
foo.push("world");

이 과정이 데이터의 불변성이 깨지는 예인 것이다. 배열 foo를 가리키는 주소는 하나임으로 이 것에 대한 데이터도 하나여야 한다는 것이다. 하지만 배열 foo는 바뀌었고 이는 데이터가 불변하지 않다는 것을 의미한다. 만약 데이터를 불변하게 유지하고 싶다면 아래와 같이 작성해야 한다.

foo = [ ...foo, "world"]

이제 foo를 가리키는 주소는 바뀌었고 원래 주소의 데이터는 바뀌지 않았으므로 이는 데이터의 불변성을 지킨 예라고 할 수 있다.

이러한 불변성을 기반으로 상태 변경이 중간에 다른 작업에 의해 방해받지 않고 완전히 수행된다.

Memoization

함수가 순수 함수라면 같은 함수를 계속 호출하는 것은 낭비이다. 그래서 마지막 인자와 결과를 추적하는 memoized version의 함수를 만든다. 이러한 방법을 통해서 우리는 같은 인자에 대해서는 함수를 재실행할 필요가 없다!

function memoize(fn) {
  var cachedArg;
  var cachedResult;
  return function(arg) {
    if (cachedArg === arg) {
      return cachedResult;
    }
    cachedArg = arg;
    cachedResult = fn(arg);
    return cachedResult;
  };
}

var MemoizedNameBox = memoize(NameBox);

function NameAndAgeBox(user, currentTime) {
  return FancyBox([
    'Name: ',
    MemoizedNameBox(user.firstName + ' ' + user.lastName),
    'Age in milliseconds: ',
    currentTime - user.dateOfBirth
  ]);
}

위의 예시는 memoize 함수를 이용해 캐싱을 하는 예시이다. 내부에 익명함수를 이용하여 입력된 arg에 대해 마지막 저장된 인수와 값을 비교하여 결과값을 출력한다.

List

대부분의 UI는 리스트 형태를 띠고 있다. 예를 들자면 댓글 목록, 상품 목록 등이 있다. 이러한 리스트에서 각 항목은 고유한 상태를 가질 수 있고 이를 효과적으로 관리하려면 각 항목의 상태를 별도로 추적해야 한다.

각 리스트 항목에 고유한 상태를 부여하기 위해서는 Map객체를 을 사용한다.

function UserList(users, likesPerUser, updateUserLikes) {
  return users.map(user => FancyNameBox(
    user,
    likesPerUser.get(user.id),
    () => updateUserLikes(user.id, likesPerUser.get(user.id) + 1)
  ));
}

var likesPerUser = new Map();
function updateUserLikes(id, likeCount) {
  likesPerUser.set(id, likeCount);
  rerender();
}

UserList(data.users, likesPerUser, updateUserLikes);

Continuations

하지만 많은 UI에서 리스트 안에 또 다른 리스트가 중첩된 복잡한 구조가 많이 사용된다. 이를 관리하려면 반복적이고 불필요한 코드(보일러플레이트 코드)가 많이 발생한다.

여기서 잠깐 보일러플레이트 코드란???

boilerplate라는 단어는 인쇄 산업에서 비롯되었다. boilerplate는 증기 보일러를 만드는 데 사용되는 강철판을 의미한다. 이는 신문사나 출판사들이 자주 반복적으로 사용하는 법률 문서, 광고, 계약서 등의 텍스트를 boilerplate으로 만들어 필요할 때마다 이를 반복해서 찍어냈고 이러한 반복적이고 변경되지 않는 내용에 대한 것에 boilerplate을 사용한다.

따라서 boilerplate code란 반복적이고 필수적이지만 비즈니스 로직과는 무관한 코드를 이야기한다.

function initializeApp() {
  console.log("Initializing app...");
  setupDatabaseConnection();
  setupServer();
  setupRoutes();
  console.log("App initialized.");
}

initializeApp을 하기 위해서는 setupDatabaseConnetion, setupServer, setupRoutes 함수 호출이 반복적으로 이루어지는데 비즈니스 로직과는 관계가 없다.

List 항목의 예시를 다시 보자

function UserList(users, likesPerUser, updateUserLikes) {
  return users.map(user => FancyNameBox(
    user,
    likesPerUser.get(user.id),
    () => updateUserLikes(user.id, likesPerUser.get(user.id) + 1)
  ));
}

var likesPerUser = new Map();
function updateUserLikes(id, likeCount) {
  likesPerUser.set(id, likeCount);
  rerender();
}

UserList(data.users, likesPerUser, updateUserLikes);

해당 코드에서 비즈니스 로직은 UserList 함수에서 사용자별로 FancyNameBox를 생성하고, 각 사용자의 좋아요 수를 증가시키는 updateUserLikes 함수 호출 로직이다.

그럼 나머지 likesPerUser, updatUserLikes와 같은 맵 객체나 함수를 사용하여 상태를 관리하고, 이를 비즈니스 로직에 전달하는 부분이 보일러플레이트 코드에 해당된다고 할 수 있다.

우리는 함수 실행을 지연시켜 이러한 보일러플레이트를 중요한 비즈니스 로직과 분리할 수 있다. 예를 들어 currying을 사용할 수 있다.

여기서 잠깐!! currying이란???

커링은 하나의 함수가 여러 개의 인자를 받는 대신, 인자 하나를 받고 나머지 인자를 받는 새로운 함수를 반환하는 기술이다. javascript의 bind 메서드를 예시로 들 수 있다.

// 일반적인 함수
function add(a, b) {
  return a + b;
}

// 커링된 함수
function curriedAdd(a) {
  return function(b) {
    return a + b;
  };
}

// 사용 예시
const add5 = curriedAdd(5);
console.log(add5(3)); // 8
console.log(add5(10)); // 15
function greet(greeting, name) {
  console.log(`${greeting}, ${name}!`);
}

// "Hello"를 고정한 새로운 함수 생성
const sayHello = greet.bind(null, "Hello");

// 사용 예시
sayHello("Alice"); // "Hello, Alice!"
sayHello("Bob"); // "Hello, Bob!"

물론 인수 바인딩을 잘 쓰지는 않는다...!
다만 가독성이 좋은 이름을 가진 독립 함수를 만들 수 있다는 장점이 있다!

이제 해당 기술을 이용하여 Continuations(지연된 실행)을 의도해보자

function FancyUserList(users) {
  return FancyBox(
    UserList.bind(null, users)
  );
}

const box = FancyUserList(data.users);
const resolvedChildren = box.children(likesPerUser, updateUserLikes);
const resolvedBox = {
  ...box,
  children: resolvedChildren
};
  • 우선 bind 메서드를 사용해서 커링을 적용했다.
  • UserList.bind(null, users) 함수는 아직 호출되지 않고 나머지 인자를 전달받아야만 실행된다.
  • 이후 box.children을 통해 UserList의 나머지 인자가 전달되고 이때 UserList는 모든 인자를 받아 실제로 실행된다.

이를 통해 FancyUserList 함수는 비즈니스 로직과 관련된 UserList의 실행을 직접적으로 다루지 않고 단순히 필요한 인자 일부를 전달하고 나머지 실행을 지연시키는 역할을 한다.
또한 box.children(likesPerUser, updateUserLikes)에서 UserList의 나머지 인자를 전달하여 함수를 실행하는 부분이 보일러 플레이트 코드이다.

이를 통해 비즈니스 로직과 보일러 플레이트 코드를 분리시킬 수 있다.

State Map

function FancyBoxWithState(
  children,
  stateMap,
  updateState
) {
  return FancyBox(
    children.map(child => child.continuation(
      stateMap.get(child.key),
      updateState
    ))
  );
}

function UserList(users) {
  return users.map(user => {
    continuation: FancyNameBox.bind(null, user),
    key: user.id
  });
}

function FancyUserList(users) {
  return FancyBoxWithState.bind(null,
    UserList(users)
  );
}

const continuation = FancyUserList(data.users);
continuation(likesPerUser, updateUserLikes);

맵 객체를 활용하여 상태를 관리하고 composition을 통해 반복 패턴을 추상화했다.

Memoization Map

리스트의 여러 항목을 메모이제이션할 때는 메모리 사용량과 성능의 균형을 맞추기 위해 더 정교한 전략이 필요하다. 하지만 UI에서는 동일한 위치에 있는 요소가 매번 동일한 값을 가지는 경향이 있다. 이 트리 구조(DOM 트리)가 메모이제이션에 매우 유용한 전략이 된다.

function memoize(fn) {
  return function(arg, memoizationCache) {
    if (memoizationCache.arg === arg) {
      return memoizationCache.result;
    }
    const result = fn(arg);
    memoizationCache.arg = arg;
    memoizationCache.result = result;
    return result;
  };
}

function FancyBoxWithState(
  children,
  stateMap,
  updateState,
  memoizationCache
) {
  return FancyBox(
    children.map(child => child.continuation(
      stateMap.get(child.key),
      updateState,
      memoizationCache.get(child.key)
    ))
  );
}

const MemoizedFancyNameBox = memoize(FancyNameBox);

Algebraic Effects

여러 계층의 추상화를 거치면서 필요한 작은 값을 모두 전달하는 것은 꽤 성가신 일이다.(props drilling)
때로는 중간 단계를 거치지 않고 두 추상화 계층 간에 데이터를 전달하는 지름길이 있으면 좋고 React에서는 이를 context라고 부른다.
때로는 데이터 의존성이 추상화 계층 구조를 따르지 않을 때도 있다. 예를 들어 레이아웃 알고리즘에서는 자식 요소의 크기에 대한 정보를 알아야 부모 요소의 크기나 위치를 완전히 결정할 수 있다.

여기서 잠깐!!! Algebraic Effects에 대해 먼저 알고 가자!

함수형 프로그래밍에서 효과(effect)를 다루기 위한 강력한 도구로 효과를 명시적으로 모델링하고 처리할 수 있게 해준다.

//Log라는 효과를 선언
effect Log : String -> ()

//Log 효과를 발생
function logMessage(message) {
  raise Log(message);
}

function main() {
  logMessage("Starting the program");
  // Some other logic
  logMessage("Ending the program");
}

// 효과 핸들러
handle Log with {
  case Log(msg) -> {
    printToConsole(msg);  // 효과 처리: 메시지를 콘솔에 출력
    continue();  // 프로그램의 실행을 계속 진행
  }
}

main();

이런 식의 가상의 언어로 예시를 들었다. 아직 Algebraic Effects는 실험적인 기능으로 일부 언어에서만 지원한다고 한다...

//효과의 종류 선언
function ThemeBorderColorRequest() { }


function FancyBox(children) {
  //raise와 new를 통해 새로운 인스턴스(new) 효과 발생
  const color = raise new ThemeBorderColorRequest();
  return {
    borderWidth: '1px',
    borderColor: color,
    children: children
  };
}

function BlueTheme(children) {
  return try {
    children();
  } catch effect ThemeBorderColorRequest -> [, continuation] {
    continuation('blue');
  } //효과를 잡아 어떤 값을 반환할지 결정(-> 표기와 catch effect는 공식적인 표기가 아님)
}

function App(data) {
  return BlueTheme(
    FancyUserList.bind(null, data.users) //위 예시의 구현 내용 확인
  );
}

이렇게 FancyBox는 효과를 통해 중간 레이어를 거치지 않고 바로 테마 색상 요청이 발생한 지점에서 직접 color를 설정한다. 또한 BlueTheme는 효과를 감지하여 직접 처리한다. 여기서 중요한 것은 FancyBox가 App으로 부터 색상 props를 명시적으로 전달 받지 않았다는 것이다.

profile
작은 문제를 하나하나 해결하며, 누군가의 하루에 선물이 되는 코드를 작성해 갑니다.

0개의 댓글