gRPC 심층 탐구: 현대적인 서버 통신의 모든 것

오늘은 현대 웹 개발에서 중요한 개념인 gRPC에 대해 자세히 알아보겠습니다.

처음 들어보는 분들도 걱정 마세요. 차근차근 설명해드리겠습니다.

gRPC란 무엇인가?

gRPC는 'google Remote Procedure Call'의 약자입니다. 이는 Google에서 개발한 오픈소스 RPC (Remote Procedure Call) 프레임워크입니다. RPC란 분산 컴퓨팅 환경에서 한 프로그램이 네트워크를 통해 다른 컴퓨터에 있는 프로그램의 프로시저(함수)를 실행할 수 있게 해주는 프로토콜입니다.

쉽게 말해, gRPC는 서로 다른 컴퓨터나 서버가 마치 같은 컴퓨터에서 함수를 호출하는 것처럼 통신할 수 있게 해주는 시스템이에요.

gRPC의 주요 특징

1. 높은 성능: HTTP/2 기반으로 동작하여 기존 REST API보다 훨씬 빠른 통신이 가능합니다.
2. 강력한 타입 체크: Protocol Buffers를 사용하여 데이터 구조를 정의하므로, 타입 관련 오류를 줄일 수 있습니다.
3. 다국어 지원: C++, Java, Python, Go, Ruby, C#, Node.js 등 다양한 프로그래밍 언어를 지원합니다.
4. 양방향 스트리밍: 클라이언트와 서버 사이의 실시간, 양방향 통신을 지원합니다.
5. 높은 효율성: 바이너리 형식의 데이터 전송으로 네트워크 사용량을 줄일 수 있습니다.

gRPC는 어떻게 작동하나요?

gRPC의 작동 방식을 좀 더 자세히 살펴보겠습니다.

1. Protocol Buffers

gRPC는 데이터 직렬화를 위해 Protocol Buffers(protobuf)를 사용합니다. Protocol Buffers는 구조화된 데이터를 직렬화하기 위한 언어 중립적, 플랫폼 중립적인 확장 가능한 메커니즘입니다.

예를 들어, 사용자 정보를 나타내는 Protocol Buffers 정의는 다음과 같을 수 있습니다:

message User {
  string name = 1;
  int32 age = 2;
  string email = 3;
}

 

 

이 정의는 컴파일되어 다양한 프로그래밍 언어에서 사용할 수 있는 코드로 변환됩니다.

2. 서비스 정의

gRPC에서는 서비스를 .proto 파일에 정의합니다. 이는 서버가 제공할 메서드와 해당 메서드의 요청 및 응답 타입을 지정합니다.

service UserService {
  rpc GetUser (GetUserRequest) returns (User) {}
  rpc ListUsers (ListUsersRequest) returns (stream User) {}
}

message GetUserRequest {
  string user_id = 1;
}

message ListUsersRequest {
  int32 page_size = 1;
}


이 예제에서 `GetUser`는 단일 응답을, `ListUsers`는 스트림 응답을 반환합니다.

 

3. 코드 생성

gRPC 도구는 이 서비스 정의를 기반으로 서버와 클라이언트 코드를 자동으로 생성합니다. 이 생성된 코드는 저수준의 통신 세부사항을 처리하므로, 개발자는 비즈니스 로직에 집중할 수 있습니다.

 

4. 통신 과정

1. 클라이언트가 로컬 객체에서 메서드를 호출합니다.
2. gRPC 프레임워크가 이 호출을 프로토콜 버퍼 메시지로 직렬화합니다.
3. 메시지는 HTTP/2를 통해 서버로 전송됩니다.
4. 서버에서 gRPC 프레임워크가 메시지를 역직렬화하고 해당하는 서버 메서드를 호출합니다.
5. 서버의 응답도 같은 과정을 거쳐 클라이언트로 전송됩니다.

 

gRPC vs REST API

REST API는 웹 개발에서 널리 사용되는 아키텍처 스타일입니다. gRPC와 REST는 둘 다 서버 간 통신을 위한 방식이지만, 몇 가지 중요한 차이가 있습니다:

1. 프로토콜
   - REST: 주로 HTTP/1.1을 사용
   - gRPC: HTTP/2를 사용하여 더 빠른 통신과 스트리밍을 지원

2. 데이터 형식
   - REST: 주로 JSON이나 XML을 사용
   - gRPC: Protocol Buffers를 사용하여 더 작고 빠른 바이너리 형식 사용

3. API 계약
   - REST: OpenAPI(Swagger)와 같은 도구로 문서화
   - gRPC: .proto 파일로 서비스를 정의하고, 이를 통해 클라이언트/서버 코드 자동 생성

4. 양방향 스트리밍
   - REST: 기본적으로 지원하지 않음
   - gRPC: 양방향 스트리밍을 기본적으로 지원

5. 언어 지원
   - REST: 거의 모든 프로그래밍 언어에서 사용 가능
   - gRPC: 주요 언어 대부분 지원하지만, REST보다는 제한적

6. 사용 편의성
   - REST: 익숙하고 이해하기 쉬움, 브라우저에서 직접 테스트 가능
   - gRPC: 초기 설정이 더 복잡할 수 있으나, 장기적으로 타입 안정성과 성능 이점

gRPC의 사용 사례

gRPC는 다음과 같은 상황에서 특히 유용합니다:

1. 마이크로서비스 아키텍처: 여러 작은 서비스 간의 효율적인 통신에 적합
2. 실시간 통신 시스템: 낮은 지연시간과 높은 처리량이 필요한 경우
3. 다국어 환경: 서로 다른 프로그래밍 언어로 작성된 서비스 간 통신
4. 리소스 제한 환경: 모바일 앱과 서버 간 통신 등 네트워크 대역폭이 제한된 환경
5. 대규모 데이터 처리: 대량의 데이터를 효율적으로 전송해야 하는 경우

 

결론

gRPC는 현대 백엔드 개발에서 중요한 기술입니다. 높은 성능, 강력한 타입 체크, 다국어 지원 등의 특징으로 인해 특히 마이크로서비스 아키텍처에서 큰 강점을 발휘합니다. 처음에는 개념이 복잡하게 느껴질 수 있지만, 실제로 사용해보면 그 강력함을 체감할 수 있습니다. REST API에 익숙한 개발자라면 gRPC를 배우는 것이 새로운 도전이 될 수 있지만, 그만한 가치가 있는 기술임을 강조하며 글을 마칩니다.


1. 테스트 코드의 정의와 사용 이유

테스트 코드(Test Code)란 소프트웨어 개발 후 기능과 동작을 테스트하는 데 사용되는 코드이다. 개발자가 예상한대로 프로그램이 실행하는 지 확인하는 역할을 한다. 어떤 기능을 테스트할 것인지에 대해 각각 테스트 케이스를 분류하고, 다양한 라이브러리와 프레임워크를 이요해 작설항 수 있다. 테스트 코드를 작성하면 그만큼 자원이 더 낭비된다는 단점이 있지만, 이를 뛰어넘는 장점이 있기 때문에 사용이 권장된다. 

  1. 장애 방지: 테스트 코드를 통해 출시 이전 소프트웨어의 결함을 찾아내고 미리 수정을 할 수 있다. 이를 통해 회사는 고객 신뢰도를 지키고 낭비되는 장애 복구 비용을 절약할 수 있다. 
  2. 협업 증진: 중간 작업 결과를 쉬벡 공유할 수 있는 수단이 된다. 불필요한 문서 작업 대신 테스트 코드 결과를 보여주면 되니 협업 과정이 훨씬 빨라진다. 
  3. 코드 품질 향상: 개발 과정에서 반복적인 테스트와 버그 수정을 통해 전반적인 소프트웨어 품질을 향상시킬 수 있다. 또한 의존성이 높은 코드에 대한 테스트 코드는 짜기 어렵다는 특성 때문에, 테스트 코드를 작성하는 과정에서 의존성이 낮은 좋은 코드를 개발할 수 있게 된다.
  4. 리팩토링 지원과 코드 자신감: 테스트 코드가 존재하면 코드 수정, 특히 구조 변경과 같은 대규모 수정에 있어 좀 더 안심하고 진행할 수 있다. 코드 수정 시 기능이 전과 같이 돌아가지 않을 걱정이 생기는 것은 개발자로서 보편적으로 겪는 고민인데, 테스트 코드를 통해 대규모 리팩토링 진행하면서도 정상적으로 동작하는 지 확인 가능하기 때문이다.

 

2. 테스트 코드의 종류와 예시

테스트 코드는 무엇을, 왜 테스트 하느냐에 따라 종단 테스트(E2E), 통합 테스트(Integration), 단위 테스트(Unit Test), 정적 테스트(Static) 등 다양한 종류가 있다. 아래는 Kent.dot.C가 테스트 코드 분류를 설명한 테스트 코드 트로피이다. (KentDotC가 리액트의 저명사인만큼 프론트엔드에 좀 더 치우쳐져 있는 점은 고려해야 한다.) 그 외에 Snapshot 테스트 등 다양한 테스트가 다양한 이름으로 불리우고 있지만, 크게 아래 4가지 분류 안에 속하는 것 같다. 특히 이 중 Unit Testing과 Integration Testing이 개발 시 가장 많이 사용되는 듯 하다. 그럼 한 번 각자 간단히 알아보자.

https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications

 

1. 정적 테스트(Static Testing)

소프트웨어를 실행하지 않은 상태에서 오류를 찾아내는 테스트 방법이다. 정적 테스트에는 코드 리뷰, 워크 스루, 인스펙션, 정적 분석 도구 사용 등 다양한 방식이 포함된다. 코드 리뷰(Code Review)는 개발자가 작성한 코드를 동료 개발자들이 검토하는 과정이다. GitHub 등에서 PR 날리고 merge 전에 하는 그 과정 말이다. 워크 스루(Walkthrough)는 좀더 본격적인 팀 간의 코드 검토 과정으로, 작성된 코드나 문서를 작성자가 설명하고 팀이 이를 검토하는 비공식적 회의이다. 자신의 코드를 PPT로 소개하고... 단체 피드백을 받는 그런 과정을 말하는 듯 하다. 인스펙션(Inspection)은 코드나 문서의 결함을 체계적으로 찾기 위해 사전에 준비된 체크리스트와 절차를 따르는 공식적 검토 과정이다. 마지막으로 정적 분석 도구(Static Analysis Tools)는 평소 사용하던 ESLint가 그 대표적인 예이다. 코드 규칙을 검사하고, 잠재적 오류를 탐지해 개발 초기 단계에서 버그를 발견할 수 있다. 

 

2. 단위 테스트(Unit Testing)

더 이상 나눌 수 없는 가장 작은 단위의, 다른 기능과 섞이지 않은 독립적인 기능을 테스트한다. 라이브러리는 언어에 따라 다양하게 존재하며 대표적으로는 JavaScript Jest, Java JUnit 있다. 참고로 단위 테스트 코드를 먼저 작성한 이에 따라 기능을 작성하는 TDD(Test-Driven Development) 통해 개발하면 불필요한 기능 개발에 들어가는 시간을 줄일 있다.

 

아래는 단위 테스트 코드의 개념을 이해하기 위한 간단한 예시이다. 각각의 독립적인 기능1(sum), 기능2(subtract)을 Jest 라이브러리를 이용해 각각 테스트하는 코드이다. 

export function sum(a, b) {
	return a + b;
}
export function subtract(a, b) {
	return a - b;
}
import { sum, subtract } from "./utils"
describe('산수 계산 함수 테스트', () => {
    test('1+2의 결과는 3이다.', () => {
        expect(sum(1, 2)).toBe(3);
    })
        
    test('1-2의 결과는 -1이다.', () => {
        expect(subtract(1, 2)).toBe(-1);
    })
})

 

3. 통합 테스트(Integration Testing)

각 독립적인 기능들의 상호 작용을 테스트한다. 기능들이 각각 잘 동작한다면, 이것들이 모여서 함께 동작할 때도 잘 돌아가는 지 테스트한다. 대표적인 통합 테스트 코드 라이브러리로는 JavaScript Testing Library과 Storybook, Java testcontainers 등이 있각 통합 단위에 따라(화면 단위인지, 기능 단위인지), 대상에 따라(UI만 테스트, 기능만 테스트, 둘 다 테스트) 다양한 테스트가 있는 것 같다. 예를 들어 프론트엔드에서의 화면 단위 UI 테스트인 Snapshot 테스트도 여기에 해당된다(고 이해했다.) 

 

아래는 각각의 기능이 모여 복리 계산 기능을 잘 이루는지를 테스트한다. 

import { render, screen, fireEvent } from '@testing-library/react';
import CompoundInterestCalculator from './App';

describe('복리 계산 앱 실행', () => {
	test('원금 100만원을 연이율 10%의 복리로 3년간 은행에 예금한다면, 3년 후 원리 합계는 1,331,000원이다.', () => {
		render(<CompoundInterestCalculator />);

		const 예치금 = screen.getByRole('input', { name: /예치금/ });
        	const 연이율 = screen.getByRole('input', { name: /연이율/ });
		const 기간 = screen.getByRole('input', { name: /기간/ });
		const 계산버튼 = screen.getByRole('button');

		fireEvent.change(예치금, { target: { value: '1000000' } });
		fireEvent.change(연이율, { target: { value: '10' } });
		fireEvent.change(기간, { target: { value: '3' } });
		fireEvent.keyDown(계산버튼, { key: 'Enter' });
        	await waitFor(() => {
            		expect(screen.queryByText('결과')).toBe('1,331,000');
        	});
    })
})

 

 

4. 종단 테스트(End-To-End Testing, E2E Testing)

사용자의 입장에서 소프트웨어가 처음부터 끝까지 정상적으로 흐름이 이어지는 지 확인하는 테스트이다. 보통 E2E 테스트라 불린다. 이러한 E2E테스트는 UI뿐만 아니라 기능까지 테스트하기 떄문에 시간과 비용이 많이 든다는 단점이 있다. GUI를 통해 화면에 컴포넌트가 실제로 렌더링 되는 모든 과정까지 테스트하고, 서버에 실제로 API 요청을 보내기 때문에 다른 테스트와는 차원이 다른 시간과 비용이 소모되며, 테스트 실패 가능성도 높다. 따라서 E2E 테스트는 중요한 기능 위주로만 적용해야 한다. 보통 실제 서비스에서는 서비스 장애를 잡을 목적으로 일정 시간(ex. 4시간) 이러한 E2E 테스트가 동작하도록 테스트 자동화를 해둔다고 한다. 사용 라이브러리로는 Cypress, Playwrite 등이 있다.

 

3. 어떤 테스트 코드를 언제 써야할까?

https://martinfowler.com/bliki/TestPyramid.html

 

다양한 테스트 코드의 종류와 그 쓰임에 대해 알아보았다. 종류마다 쓰임이 다른 것처럼 그 비용도 다 다르다. 그리고 실세계에서는 우리는 늘 그 비용을 고려할 수 밖에 없다. 그 유명한 "테스트 피라미드" 개념에 따르면 테스트 비용은 앞서 설명된 Static, Unit 단위에서 가장 저렴하고 빠르게 시행할 수 있으며, 로직이 섞이는 Integration 단위부터 비용과 그 무게가 증가한다. Server와 통신까지 필요해지는 E2E 테스트는 그 값이 비싸지고 가장 시행하기 무겁다. 

 

이러한 테스트 특징을 고려했을 때, 쓰임과 비용을 고려해 적절히 사용하는 것이 좋다. Martin Fowler 무겁고 값이 비싼 E2E 테스트만 시행하고 버그를 통해 고쳐나가기보다는, 작고 가벼운 Unit Testing와 Integration Testing 을 자동화하여 더 자주 실행하는 것을 추천한다. "So the pyramid argues that you should do much more automated testing through unit tests than you should through traditional GUI based testing." 추가적으로, Kent C. Dodds는 그 중 가장 가성비 좋은 Integration 단위 테스트를 가장 많이 사용할 것을 권장한다. "Write tests. Not too many. Mostly integration.”

 

두 거장에게서 반복적으로 언급된 통합 테스트(Integration Testing)는 앞서도 간략히 설명했지만, 통합 단위에 따라(화면 단위인지, 기능 단위인지), 대상에 따라(UI만 테스트, 기능만 테스트, 둘 다 테스트) 등 다양한 기준에 따라 다양한 테스트가 있는 것으로 보인다. 특히 다양한 컴포넌트들 화면 단위로 테스트를 하는 UI 통합 테스트인 Snapshot test와, API개발이 미뤄지는 현업 상황에서 기능 테스트를 실용적으로 도와줄 msw와 jest를 사용한 통합 기능 테스트, 이 2가지 통합 테스트가 프론트 개발에서는 가장 효율성 좋은 Integration Testing이지 않을까 생각한다. 여기서 한 발짝 더 나아간다면, 정말 '이 기능 오류나면 서비스가 망한다' 싶은 중요한 기능들만 추려 Cypress를 통해 E2E테스트까지 적절한 텀으로 자동화해 시행하면 좋지 않을까 싶다.

 

4. 통합 테스트(Integration Testing) 직접 적용해 보기: msw, jest, Storybook

(작성 중)

 

5. 테스트가 용이한 코드란 어떤 코드인가?

(작성 예정)

 


참조

1. 

https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications

 

The Testing Trophy and Testing Classifications

Stay up to date Stay up to date All rights reserved © Kent C. Dodds 2024

kentcdodds.com

 

2.

https://martinfowler.com/bliki/TestPyramid.html

 

bliki: Test Pyramid

Write most of your tests at a low level (unit tests) with a few broad-stack tests, eg via UI. UI tests tend to be fragile and slow.

martinfowler.com

 

3.

https://www.epicweb.dev/good-code-testable-code

 

Good Code, Testable Code

Learn what testability means, how it relates to code complexity, and why it's essential for effective testing.

www.epicweb.dev

 

4.

https://fe-developers.kakaoent.com/2022/220825-msw-integration-testing/

 

MSW를 활용하는 Front-End 통합테스트 | 카카오엔터테인먼트 FE 기술블로그

송기연(Kaki) 음악과 별을 좋아하는 개발자입니다.

fe-developers.kakaoent.com

 

5.

https://blog.banksalad.com/tech/test-in-banksalad-ios-3/

 

뱅크샐러드 iOS팀이 숨쉬듯이 테스트코드 짜는 방식 3편 - 스펙별 단위 테스트 | 뱅크샐러드

안녕하세요! 뱅크샐러드에서 iOS…

blog.banksalad.com

 

6.

https://fe-developers.kakaoent.com/2023/230209-e2e/

 

E2E 테스트 도입 경험기 | 카카오엔터테인먼트 FE 기술블로그

방경민(kai) 사용자들에게 보이는 부분을 개발한다는 데서 프론트엔드 개발자의 매력을 듬뿍 느끼고 있습니다.

fe-developers.kakaoent.com

 

 

 

Last Edited: 24/10/14

 

 

목차

1. 서문

2. React에서 상태를 관리하는 방법

- 기본 상태 관리 방법

- 내부 기능 훅 사용하기

- 외부 라이브러리 사용하기

3. 상태 관리를 "잘"하는 방법


1.  서문

최근 공개된 Figma Config 2024에도 나왔고, IT업계 선두 쪽 회사에 다니는 지인들로부터도 Figma, Framer 등을 활용한 디자이너와 프론트엔드의 경계가 모호해진다는 등 여기저기서 소리가 들려온다. 변해가는 프론트엔드 역할을 볼 때 프론트엔드 개발자하면 더 기대되는 부분은 UI적 부분보다는 "데이터"를 다루는 "상태관리"를 얼마나 잘 하는지인 것 같다는 생각을 했다[]. 

 

사실 상태 관리는 어렵다. 왜 어려울까. 무효화, 동기화, 파생 상태, 서버 재요청 등 예기치 못한 리렌더링의 상황이 많아서이다. 개인적으로 이 상태 관리가 너무 어려워서 에러로 쪽(?)도 많이 팔았다. 그래서 이런저런 이유로 상태 관리를 제대로 공부해보기로 했다. 상태관리란 무엇이며, 어떻게 해야하는 것일까. 어떻게 하는 것이 상태관리를 "잘"하는 것일까?


2. React 에서 상태를 관리하는 방법

"상태 관리"란 무엇인가요?
리액트에서 상태(state)란 컴포넌트 속 데이터를 의미합니다. 이러한 상태는 시간에 따라, 사용자와의 상호작용에 따라 업데이트되며, 이렇게 변화하는 상태를 앱의 다양한 부분이 서로 일관되게 공유하여 사용자의 상호작용에 올바르게 반응하게 해주는 관리를 상태 관리라고 합니다. 상태 관리에는 컴포넌트 내부의 데이터의 변화를 관리하는 지역적 상태 관리(local statement management)와 컴포넌트와 외부 컴포넌트끼리의 상태 공유를 관리하는 전역 상태 관리(global statement management)가 있습니다.

2-1. 기본 상태 관리 방법

- state 란...

- props 란...

- state를 업데이트 하는 방법, setState

- props를 업데이트 하는 방법, propsDrilling

 

상태를 관리하는 데에 있어 라이브러리가 반드시 필요할까요?
리액트에서 상태 관리를 하는 기본적인 방법은 컴포넌트의 state와 props를 사용하는 것입니다. props 는 부모 컴포넌트로 전달 받은 데이터를 의미합니다. 컴포넌트의 state는 setState 함수를 통해 업데이트할 수 있으며, 이 과정에서 리액트는 컴포넌트를 재렌더링하여 변경된 데이터를 사용자에게 보여줍니다. 그러나 이것은 컴포넌트 내부의 상태 관리일 뿐, 어플리케이션을 효과적으로 동작하게 하기 위해서는 여러 컴포넌트 간에 상태를 공유하고 관리할 수 있어야 합니다. 리액트는 단방향 흐름인 Flux 패턴을 바탕으로 하므로, 데이터 흐름이 단순하지만 단계별로 일일이 props를 넘겨주어야 하는 번거로움이 있습니다. 이는 규모가 어느 정도 큰 애플리케이션에서는 오히려 데이터 관리가 복잡해지는 결과를 낳기도 합니다. 따라서 트리 단계마다 명시적으로 props를 넘겨주지 않아도 컴포넌트 트리 전체에 데이터를 제공할 수 있게 만들어주는 상태 관리 라이브러리는, 복잡한 상태 관리가 필요한 경우 사용하는 것이 효율적인 면에서 필요하다고 생각합니다. 

하지만 반드시 외부 라이브러리를 사용할 필요는 없습니다. 앱의 규모가 크지 않다면, 내부 상태 관리 라이브러리인 useContext와 useReducer 등을 사용하는 것을 고려해 볼 수 도 있습니다.

2-2. 내부 기능 훅 사용하기

- 훅(hook)이란 ... 

- useState, useContext, useReducer

- useReducer의 동작 원리

 

훅(hook)이란 ... 

리액트에서 상태를 관리하기 위해 상태관리 라이브러리가 반드시 필요하지는 않습니다. 하지만 앱의 규모가 커질 수록, 상태를 공유해야 하는 컴포넌트 사이의 거리가 멀수록, 컴포넌트끼리의 상태 공유를 보다 효율적하기 위해서 전역 상태 관리는 반드시 필요합니다. 예전 클래스형 리액트를 사용할 때는 이러한 전역 상태 관리를 위해 Redux 등 외부 라이브러리를 사용해야 했습니다. 그러나 2019년, React 16.8에서 훅(Hook) 기능을 소개하며, 여러 편리한 기능과 함께 상태 관리에 대한 대안을 제시했습니다. 특히 이러한 훅 중 useContext, useReducer 의 훅을 사용하면 반드시 외부 라이브러리를 사용할 필요 없으며, 이를 훅을 이용한 상태관리라고 하기도 합니다. 이러한 훅은 기본적으로 함수이며 use...로 시작하는 것이 관례입니다.

 

useContext, useReducer와 useState

useContext와 useReducer, 그리고 앞서 배운 useState는 사실 이러한 훅 중 상태 관리 기능에 특화된 훅입니다. useState가 상태 그 자체와 상태를 업데이트하는 함수를 동시에 정의한다면, useContext와 useReducer는 각각 상태와 상태 업데이트 방식을 그 기능을 확장하여 분리한 개념입니다. useContext에서 컨텍스트(Context)는 모든 컴포넌트에서 직접 접근 가능한 전역 데이터 객체로, 단순 지역 상태를 넘어 전역적으로 어느 컴포넌트에서든 접근하고 사용할 수 있는 상태 정보를 가진 거대한 컨테이너 같은 개념이빈다. 이러한 useContext를 사용하여 전역 상태 관리가 가능하게 해짐으로써, 외부 라이브러리 사용의 필요성이 낮아지게 되었습니다.

 

한편 useReducer는 상태 업데이트 방법으로, useState에서 제공하는 상태 업데이트 방식보다 더 복잡한 상태 업데이트를 보다 간단한 방식으로 처리해줍니다. 리액트는 이 두 훅에 의해서만 상태 변화를 인지합니다. 주의할 점은 이  두 훅이 아닌 다른 방식으로 상태를 업데이트하면 리액트는 상태 변화를 인지하지 못하기 때문에 리렌더링이 원하는대로 발생하지 않을 수 있습니다. 이는 뒤 3.상태관리를 잘하는법에서 더 자세히 다루겠습니다. 또한 useContext와 useState, useReducer의 구분은 전역, 지역 상태의 구분이라기 보다 상태와 상태 업데이트 방식의 구분입니다. 전역, 지역 상태는 상태의 범위를 컴포넌트 내부로 제한하느냐, 아니냐에 따라 나눈 구분이며, 상태과 상태 업데이트 방법은 지역성과 상관없이 역할에 대한 구분이므로 헷갈리지 않으시면 좋겠습니다.

 

useReducer의 동작 원리는 외부 상태 관리 라이브러리인 Redux와 유사합니다. 두 라이브리 모두 Flux 패턴을 사용합니다. Flux 패턴은 단방향으로 데이터 변경이 이루어지는 구조로, 다음 네 가지 개념으로 구성됩니다: Action, Dispatcher, Store(Model), View.

  • Action: 변경 데이터와 변경 로직 타입을 정의합니다.
  • Dispatcher: 구체적인 변경 로직을 담당합니다. useReducer의 reducer가 이 역할을 수행합니다.
  • Store(Model): 상태를 저장합니다.
  • View: 상태를 기반으로 UI를 렌더링합니다.

useReducer의 reducer는 state(기존 정보)와 action을 받아 기존 데이터를 업데이트하는 역할을 합니다. 결국 useReducer는 state와 state를 변경하는 동작(dispatch)을 하나로 합친 형태입니다. 따라서 state를 여러 번 언급할 필요가 없고, 여러 곳에서 반복되는 업데이트 로직을 reducer에 한 번만 정의한 후 여러 곳에서 사용할 수 있어 코드가 더 간결해집니다. 이는 코드의 직관성을 높여줍니다.

 

useReducer는 여러 상태를 효율적으로 관리하고, 업데이트 로직을 단순화하여 코드를 간결하게 만들어주는 훅입니다. (*reducer: a function that reduce one or more complex values to a simpler one.) 이는 Redux와 같은 상태 관리 라이브러리의 패턴을 따르며, 복잡한 상태 관리가 필요한 경우 유용합니다. useState는 단 하나의 상태만 관리할 수 있지만, useReducer를 이용하면 동시에 여러 상태들을 한 번에 관리할 수 있게 됩니다. 이러한 useReducer는 2024년 2월 기준 useState를 구현되어 있으며, 따라서 useState로 useReducer를 구현하는 것은 100%로 가능합니다. (향후 더 효율적으로 구현될 수 도 있습니다.) useReducer는 useState와 비교하여 순수함수이며, 외부에서 정의가 가능한 등 마이너한 차이점이 있지만, 특별한 사항이 아니면 중요치 않기 때문에, 기본적으로 useState와 useReducer는 기본적으로 동일며 상호 교환도 가능하다고 봅니다. 다만, useReducer 함수는 ‘순수함수’(어떤 상황이든 같은 input이면 같은 output을 내뱉는, 외부와 독립적이어 사이드이펙트가 없는 함수)라 동작을 테스트하기 더 쉽다는 이점으로 더 선호되는 부분도 있습니다.

 

2-3. 외부 라이브러리 사용하기

상태 관리 외부 라이브러리는 전역적인 상태 관리를 돕는 전역 상태 관리 라이브러리와 특정 상태의 특수성을 고려한 특수 목적형 상태 관리 라이브러리로 나뉩니다. 예를 들어, 전역 상태 관리 라이브러리에는 Redux, Recoil, 그리고 MobX 등이 있으며, 특정 상태 관리 라이브러리는 서버 상태 관리를 돕는 react-query, 폼의 상태 관리를 돕는 Formik 등이 있습니다. 이 중 실제로 사용해 본  가지를 특징과 함께 사용 후기를 비교하여 설명해보고자 합니다.

 

왜 OO라이브러리를 사용하셨나요? 

이런 질문은 항상 면접에서 빠지지 않고 듣는 것 같습니다. 그만큼 라이브러리를 무작위로 사용하지 않고 특징을 파악해 사용해 불필요한 리소스 사용을 줄이는 것이 엔지니어로서의 덕목이기 때문이겠죠. 우선 직접 사용해본 전역 상태 관리 라이브러리 두 가지를 공부하고 사용하며 느낀 그 특징을 설명해보겠습니다. 이를 바탕으로 질문에 답할 수 있을 것입니다. 

 

1. Redux 

Redux 가장 오래된 상태 관리 라이브러리입니다. React Hooks(2019 2)보다 이전에 출시(2015 6)되어, 클래스형 컴포넌트로 상태를 관리하던 시절부터 전역 상태 관리를 위해 사용되던 상태 관리의 조상격 라이브러리입니다. 특징은 아래와 같습니다.

 

Redux의 기본 동작 원리와 특징: Flux 패턴

  • 단방향 데이터 흐름: 상태 추적이 일관적이고 쉬워 팀원들 간에 공유하기 쉽습니다. Action, Dispatcher, Store(Model), View로 이루어진 Flux 패턴의 단방향 흐름은 앞서 useReducer를 설명하며 간단히 언급하였습니다. 더 자세한 동작 방식은 다음 을 참조할 수 있습니다.
  • 순수 함수: 역시 앞서 설명되었지만, 순수함수는 어떤 상황이든 같은 input이면 같은 output을 내뱉는, 외부와 독립적이어 사이드이펙트가 없는 함수를 의미합니다. 이러한 특성 덕분에 무한 되돌림, 무한 재생이 가능하며, Redux의 특징인 시간 여행도 가능합니다.
  • 보일러플레이트 코드: 특정 패턴을 바탕으로 하기 때문에 설정 과정에서 많은 보일러플레이트 코드가 필요하여 진입 장벽이 높습니다.

그 외 특징: 커뮤니티 지원

  • DevTools: 강력한 개발 도구를 제공합니다.
  • 방대한 자료: 가장 오래된 상태 관리 라이브러리로 관련 자료가 많습니다.

 

2. Recoil

Recoil 2020 5월에 Facebook 일부 React 개발자들이 출시하였으며, 현재 많은 개발자들에게 인정을 받고 있습니다.

 

Recoil의 기본 동작 원리

 

  • 분산 상태: Recoil은 상태를 여러 작은 Atom(state)으로 나누어 관리할 수 있게 해줍니다. 이는 상태 관리의 복잡성을 줄이고, 특정 상태 변경이 애플리케이션 전체에 영향을 미치지 않도록 합니다.
  • 증분 상태: Recoil의 Selectors는 파생된 상태(derived state)를 관리하기 위해 사용되며, 다른 Atom이나 Selector의 값을 기반으로 계산됩니다. 이는 상태의 재사용성을 높이고, 필요에 따라 상태를 동적으로 계산할 수 있게 해줍니다.
  • useRecoilState: Recoil에서의 상태 업데이트 방법입니다.

그 외 Recoil의 특징

 

  • 지엽적인 상태 관리: Atom식 상태 관리 특징에 따라 지엽적인 상태 관리가 더 많이 필요한 서비스의 경우 사용되면 좋을 것이라 판단됩니다. 전체적인 팀 공유가 어려워 질 수 있다는 단점이 있지만, 특정 상태 관리가 더 필요한 경우 유용합니다.
  • 낮은 러닝 커브와 진입 장벽: React의 Context API와 거의 동일한 방식으로 상태를 사용하나, 1) Provider로 감싸줄 필요가 없고, 사용법이 더 직관적이고 쉽습니다. 3) React에 익숙한 사람이라면 이미 익숙한 개념이라 배우기 쉽습니다.
  • 불완전한 개발 도구: Redux처럼 시간 여행 등의 기능이 있지만, 아직 완전하지는 않습니다. 문서 보기.

 

React Hooks 나온 이후로 상태 관리 라이브러리의 사용 빈도는 점차 낮아지는 추세이지만, 여전히 다양한 외부 상태 관리 라이브러리가 존재하며 각각의 특성과 장단점을 이해하고 활용하는 것이 중요합니다.


3. 상태 관리를 잘 하는 법

- 애초에 불필요한 상태를 만들지 않기

- 상태 변경시 불변성을 고려하기

- 리렌더링 성능을 최적화하기: memo(), useCallback()

- 컴포넌트 생명주기와 상태의 주기를 같이 하기: useEffect()

 

리액트의 "불변 철학"이란 무엇인가요? 리액트의 "상태 감지"에 대해 아는대로 말해주세요.

리액트는 상태 변경 기존 상태를 직접 수정하지 않고 새로운 상태 객체를 반환하는 불변성을 중요시합니다. 이는 상태 관리의 예측 가능성을 높이고, 상태 변경의 추적을 용이하게 합니다. 이는 리액트의 상태 감지 방식에서 유래합니다. 리액트는 상태 변화를 객체의 참조(주소)가 변경될 떄만 상태 변화로 인식하는 "얕은 참조"를 통해 변화를 감지합니다. 따라서 원시 값의 변경이나 같은 객체의 반환은 리렌더링을 일으키지 않습니다. 또한 앞서 언급되었듯 "useState와 useReducer"를 통해 관리되는 상태 변화만 감지합니다. 일반 변수 값의 변경은 감지하지 못해 리렌더링이 일어나지 않습니다. 따라서 같은 객체를 반환하거나 원시 값의 변경, useState 혹은 useReducer를 사용하지 않고 상태를 변경하는 경우 등은 베일 아웃을 야기할 수 있습니다. 이러한 리액트의 불변성을 고려하여 상태 변경을 진행해야 합니다.

 

React의 리렌더링 최적화

React는 렌더링은 두 번 일어납니다. 최초 앱을 실행할 때 한 번의 렌더링, 그리고 상태 변화가 감지되었을 때(리렌더링). 리렌더링이 발생하는 경우는 두 가지입니다. 첫째, 부모 컴포넌트의 리렌더링. 이 경우 하위 컴포넌트는 상태 변화와 상관 없이 전부 리렌더링됩니다. 둘째, 컨텍스트의 변경. 컨텍스트의 상태 변경 시, 해당 컨텍스트를 사용하는 모든 컴포넌트는 리렌더링 됩니다. 상태가 감지가 되면 리액트는 모든 부분이 아닌, 변화된 부분에 한해서 리렌더링합니다. 정확히는 상태가 변화하는 컴포넌트와 그의 모든 하위 컴포넌트를 리렌더링합니다. 변화한 부분만 리렌더링하는 자세한 내부 동작 원리는 아래와 같습니다.

  1. 가상 DOM 업데이트: 상태가 변경되면, React는 새로운 가상 DOM을 생성합니다. 가상 DOM은 실제 DOM의 가벼운 복사본입니다. 이 단계에서는 메모리 내에서만 작업이 이루어집니다.
  2. 비교 (Reconciliation): 새로운 가상 DOM과 이전 가상 DOM을 비교합니다. React는 이 비교 과정을 통해 변경된 부분을 찾아냅니다. 이를 "diffing algorithm"이라고 합니다.
  3. 실제 DOM 업데이트: 변경된 부분만 실제 DOM에 반영합니다. 이렇게 함으로써 성능을 최적화하고 불필요한 DOM 업데이트를 피할 수 있습니다.

리엑트에서의 리렌더링을 이해했다면, 상태가 업데이트되지 않은 컴포넌트의 불필요한 리렌더링 방지가 필요합니다. 이러한 불필요한 리렌더링을 방지하는 방법을 알고 있는대로 정리하면 아래와 같습니다. 

  • 애초에 잘 배치하기(Clever Structuring)
    여러 컴포넌트에서 해당 상태 사용시, 하나의 컨테이너 안에서 상태 변경을 이루게 하기 위해 반드시 해당 컴포넌트들의 공통 상위 컴포넌트에 위치시켜야 합니다. 이를 상태 끌어올리기(lift state up)이라고 합니다. 만약 각각의 컴포넌트 안에서 useState를 사용해 상태를 다루게 된다면, 이는 사실상 각각 다른 컨테이너, 즉, 다른 문맥(ex. count container1, count container 2 등)을 사용하는 것이기 때문에, 추측과 다르게 렌더링이 일어나지 않는 리렌더링 오류가 발생할 수 있으니, 반드시 상태 끌어올리기를 사용해야 합니다. 공통된 상태를 공유하는 가장 낮은 컴포넌트에 적절히 상태를 잘 위치시키며, 컴포넌트 트리 구성과 리렌더링 상태를 염두에 두고 컴포넌트를 구성하는 것이 필요합니다.
  • memo: 부모 컴포넌트의 리렌더링으로 인해 발생하는 불필요한 하위 컴포넌트의 리렌더링을 막기 위해 memo를 사용합니다. 컨텍스트를 사용하지 않는 하위 컴포넌트도 부모의 리렌더링으로 불필요하게 리렌더링 되는 경우, 이 때 memo()를 활용한다. 즉, memo는 컨텍스트 변경이 없다면(부모 렌더링으로 인한 렌더링은 못막지만) 리렌더링을 막아줍니다.
    그러나 성능 향상을 위해 memo를 무작위로 사용하는 것은 좋지 않습니다. memo의 동작 원리는, 상태 변경 시 memo로 감싼 컴포넌트의 prop의 변화를 확인하는 것입니다. prop의 변화가 있을 경우만 컴포넌트를 리렌더링합니다. 이러한 동작 원리를 이해했을 때, 불필요한 props-checking이 발생하여 성능이 저하되는 것을 막기 위해서는 꼭 필요한 상위 컴포넌트를 잘 선택해 memo를 감싸는 것이 중요합니다. 상위 컴포넌트가 리렌더링 되지 않는다면 하위 컴포넌트 역시 리렌더링 되지 않을 테니까요. 
  • useRef와 useEffect: useRef과 useEffect는 모두 렌더링 사이클과 관계없이 값을 유지해야 할 때 사용하는, 상태 관리에 도움을 주는 훅입니다. 먼저 useRef는 렌더링 사이클과 관계없이 값을 유지하는 것 + DOM과의 연결이 필요할 때 사용합니다. useEffect는 컴포넌트의 생명 주기(마운트, 업데이트, 언마운트)를 보다 간결하고 효과적으로 관리하기 위해 탄생한 훅으로, 컴포넌트의 생명 주기를 따라 동작합니다. 부수 효과(데이터 페칭, 구독 설정, 타이머 설정, DOM 조작 등)를 렌더링 로직과 분리하는 데 사용하면 효과적입니다. 모든 함수가 그러하지만 특히 useEffect 의 경우 함수를 잘 사용하는 것이 중요합니다. 아니면 상태 관리 시 꼬인 상태 때문에 곤욕을 치룰 수 있기 때문입니다. 그 방법은 다음과 같습니다. 첫째, useEffect 함수 안에는 렌더링 로직과 상관 없는 동작만 넣어야 합니다. 즉, 상태 변화를 일으키는 setState 함수 등이 들어가면 안됩니다. 리액트는 컴포넌트가 리렌더링될 때마다 useEffect 호출하므로, 불필요한 부수 효과를 방지하기 위해 상태 변경 로직은 useEffect 외부에서 처리해야 합니다. 둘째, cleanup 함수를 작성합니다. 개발 모드에서 컴포넌트가 리렌더링될 때 useEffect가 두 번 호출되는 경우를 대비해 cleanup 함수를 작성해야 합니다. useEffect 내부에서 상태 변경 훅(useState, useReducer)을 사용하지 않도록 주의합니다. 

문제 마주하기

5/23/23 Tue

문제 상황

  • 지도 생성 기능 앱을 만드려고 함.
  • 지도 생성, 마크 표시 등 모든 기능들이 현재 유저 위치를 받는 것으로 부터 시작함.
  • but, 현재 유저 위치를 받는 getCurrentPosition 내장 함수는 “비동기적”으로 수행됨.
  • 모든 코드를 App 파일에 순차적으로 때려넣었을 때는 순서대로 진행됐지만, 훅으로 분리하니 getCurrentPosition 함수가 값을 받아오는 동안 벌써 다른 함수들이 실행되는 문제가 발생함.
  • 당연히 값이 없으니 undefined 값으로 실행되고 에러가 남.

고민점

  • 어떻게 훅으로 분리할 때 비동기함수를 동기적으로 처리할 수 있을까?

접근

1: useMap 안의 setState
But, 초기값 { lat: 0, lon: 0 } 이 맨처음에 렌더링 되는 게 지저분해보임. 비동기 함수를 좀 더 깔끔히 다루는 방법이 있을 것 같음.

// useMap.tsx

const useMap = () => {
  const [currentPosition, setCurrentPosition] = useState({ lat: 0, lon: 0 });

  const getUserPosition = async () => {
    if (!navigator.geolocation)
      throw new Error("위치 접근 권한을 허용해주세요");

    navigator.geolocation.getCurrentPosition((position) => {
	    const lat = position.coords.latitude;
	    const lon = position.coords.longitude;
	
	    setCurrentPosition({ lat, lon });
	  });
  };

  //...
}
// App.tsx
function App() {
  const { currentPosition, getUserPosition, createMap } = useMap();

  if (Object.keys(currentPosition).length !== 0) {
    createMap(currentPosition.lat, currentPosition.lon);
  }

  useEffect(() => {
    getUserPosition();
  }, []);

  return (
    <div id="app">
      <div id="map" className="map-container"></div>
    </div>
  );
}

 

2. promise, async/await → return new Promise를 통해 해결.

const getUserPosition = () => {
    if (!navigator.geolocation)
      throw new Error("위치 접근 권한을 허용해주세요");

    return new Promise((resolve, reject) => {
      navigator.geolocation.getCurrentPosition((position) => {
        const lat = position.coords.latitude;
        const lon = position.coords.longitude;

        resolve({ lat, lon });
      });
    });
  };
function App() {
  const { getUserPosition, createMap, displayMarker } = useMap();

  useEffect(() => {
    getUserPosition().then((res) => {
      const { lat, lon } = res;
      const map = createMap(lat, lon);
      displayMarker(lat, lon, "내 위치", map);
    });
  }, []);

  return (
    <div id="app">
      <div id="map" className="map-container"></div>
    </div>
  );
}

 


useHttp 훅 만들기

5/24/23 Wed

 

추가적으로, 비동기함수 중 fetch처럼 상태, 에러 등을 관리할 필요가 있다면 상태관리를 위해 훅으로 빼보자.

찾아보니 그 훅은 비슷비슷한 것 같다.

  1. data, status(loading), error : 이 3가지 상태를 관리한다.
  2. useCallback과 useReducer, 그리고 dispatch를 사용한다.

상태를 다루는 방법에는 알다시피 2가지가 있다: useState, useReducer 가 그것이다. 그런데 useHttp훅은 알다시피 다뤄야할 상태가 3가지이다. 따라서 복수 상태를 다루기 더 편리한 useReducer를 사용하는 것이 편할 것이다.(물론 useState를 사용할 수 도 있다.)

2가지 방법으로 모두 훅을 만들어 사용해 보았다.

 

1. useState를 이용한 커스텀훅

// useFetchMoviesUsingUseState.tsx

import { useCallback, useEffect, useState } from "react";

const useFetchMoviesUsingUseState = (url: string) => {
  const [movies, setMovies] = useState([]);
  const [loading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchMoviesHandler = useCallback(async () => {
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error("Something went wrong!");
      }

      const data = await response.json();

      const transformedMovies = data.results.map((movieData: any) => {
        return {
          id: movieData.episode_id,
          title: movieData.title,
          openingText: movieData.opening_crawl,
          releaseDate: movieData.release_data,
        };
      });
      setMovies(transformedMovies);
    } catch (error: any) {
      setError(error.message);
    }

    setIsLoading(false);
  }, []);

  useEffect(() => {
    fetchMoviesHandler();
  }, [fetchMoviesHandler]);

  return { movies, loading, error };
};

export default useFetchMoviesUsingUseState;

2. useReducer를 이용한 커스텀훅

// useFetchMoviesUsingUseReducer.tsx

import { useCallback, useReducer } from "react";

const reducer = (state, action) => {
  switch (action.type) {
    case "SEND":
      return {
        data: undefined,
        error: null,
        status: "pending",
      };
    case "SUCCESS":
      return {
        data: action.payload,
        error: null,
        status: "completed",
      };
    case "ERROR":
      return {
        data: undefined,
        error: action.errorMessage,
        status: "completed",
      };
    default:
      return state;
  }
};
const initialState = {
  data: undefined,
  error: null,
  status: "ready",
};

function useFetchMoviesUsingUseReducer(requestFunction) {
  const [state, dispatch] = useReducer(reducer, initialState);
  const loading = state.status === "pending";

  const sendRequest = useCallback(
    async (...requestData) => {
      dispatch({ type: "SEND" });
      try {
        const responseData = await requestFunction(...requestData);
        dispatch({ type: "SUCCESS", payload: responseData });
        return responseData;
      } catch (error) {
        dispatch({ type: "ERROR", errorMessage: error.message });
        return;
      }
    },
    [requestFunction]
  );

  return {
    sendRequest,
    loading,
    ...state,
  };
}

export default useFetchMoviesUsingUseReducer;

확실히 여러가지 상태를 관리할 때에는useState보단 useReducer를 사용하는 편이 가독성이 올라간다고 생각은 하는데, 여전히 작성할 때 코드가 늘어난다는 점에서 (reducer를 따로 작성해줘야 했다) 더 편리한지는 모르겠더라.

 

🔖 학습 참조글
https://itchallenger.tistory.com/258
https://velog.io/@sae1013/Reactcustom-hook으로-httpRequest-구현하기
https://www.udemy.com/course/react-the-complete-guide-incl-redux/learn/lecture/25599794#overview

react-query 이용하기

5/27/23 Sat

 

이렇게 서버측 데이터를 불러오고 관리하는 커스텀훅을 만들다보니 하나 알게된 사실: 한 라이브러리를 쓰면 이렇게 커스텀훅을 만들지 않아도 이러한 훅을 쓴 것처럼 data, isLoading 등 상태관리를 쉽게 할 수 있게 된 것을 알게됐다. 그 라이브러리의 이름은 react-query. 그렇게 react-query에 대해서도 공부해보게 되었다.

 

react-query란?

여러 종류의 데이터(state 등) 중에서도 서버에서 받아온 데이터 상태의 관리를 도와주는 라이브러리이다. 이 글은 react-query를 상세히 다루기 위한 글이 아니어서 이에 대한 학습 및 설명글은 따로 뺐다. 아주 간단히 말하자면  처음 사용시 queryProvider로 감싸주기만 하면 그 하위에선 커스텀훅을 사용했던 것처럼 useQuery훅을 통해 어디서든 서버측 데이터와 데이터의 상태를 이용할 수 있었다. 이렇게 위에서 작성했던 모든 커스텀hook과 api함수들이 불필요해지는 편리함을 맛보았다.

 

react-query 사용시 장점

기대했던대로 수많은 보일러플레이트를 없앨 수 있을 뿐 아니라 staleTime, cacheTime등을 활용해 캐싱 기능을 더 수월하게 다룰 수 있다는 점이 인상깊었다. 또 혼자 공부하다보니 놓쳤는데 커스텀훅으로 상태관리를 하면 말그대로 ‘커스텀’이기 때문에 개발자마다 다르게 작성된다. 하지만 react-query를 사용하면 통일된 방식으로 서버측 데이터 관리가 가능해진다. 이렇게 react-query를 공부하고 사용하며 느낀 장점을 정리하면 아래와 같다:

  • 귀찮은 boilerplate x — isLoading 등 상태, 캐싱 기능
  • 팀원들끼리 통일된 방식으로 상태 관리 가능 — 누구는 isLoading, 누구는 pending 변수명을 다르게 사용하는 것을 막을 수 있다.
// index.tsx

const queryClient = new QueryClient();
console.log(queryClient);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);
// App.tsx

const { data, status, error } = useQuery(["movies"], fetchMovies, {
    refetchOnWindowFocus: false,
    retry: 0,
    staleTime: 5000,
    cacheTime: Infinity,
    onSuccess: (data) => console.log(data),
    onError: (e) => console.log(e.message),
  });

  let content = <p>No Found Movies.</p>;

  if (status === "success") {
    content = (
      <div>
        {data.results.map((movie, idx) => (
          <div key={idx}>{movie.title}</div>
        ))}
      </div>
    );
  }

  if (status === "loading") {
    content = <p>Loading...</p>;
  }

  if (status === "error") {
    content = <p>{error.message}</p>;
  }

  return <div>{content}</div>;
}

 

🔖 학습 참조글
https://tech.kakaopay.com/post/react-query-1/
https://tanstack.com/query/latest/docs/react/overview?from=reactQueryV3&original=https://tanstack.com/query/v3/docs/overview
https://velog.io/@jay/10-minute-react-query-concept#:~:text=리엑트 쿼리는 데이터,데이터를 다시 불러온다
https://2ham-s.tistory.com/407
https://kyounghwan01.github.io/blog/React/react-query/basic/
https://www.youtube.com/watch?v=novnyCaa7To

 


Sum Up

어찌어찌 하다보니 비동기함수로 시작해, 비동기 함수중에서도 fetch를 집중적으로 다루게 되었다. 나아가 fetch해온 데이터(서버측 데이터)를 프론트측에서 리액트로 관리하는 방법에 대해 찾아보게 된 시간이었다.

 

네이티브(useState/useReducer) 그리고 툴(react-query)

useState+useEffect 혹은 useReducer+useCallback를 사용해 관리할 수 있다.(이경우 훅을 따로 분리해 관리하면 깔끔하다.) 그러나 이 경우 수많은 보일러 플레이트가 필요하고, 특히 캐싱에 관련해서도 수많은 작업을 진행해줘야 한다. 또한 규명된 상태관리 폼이 존재하지 않아 팀원들끼리 공통된 상태관리가 힘들 수 있다.(다른 상태명, 로직….) 이러한 점을 보다 쉽게 해주는 것이 라이브러리겠지. redux의 saga 미들웨어와 mobx의 __을 이용할 수 있겠지만, 이들은 서버상태 전용 라이브러리가 아니다. 서버 상태 전용 라이브러리 react-query는 보일러 플레이트를 줄이며, 캐싱, 클라이언트 측과 서버 측 상태의 동기화에 특화된 인터페이스를 제공해 서버측에서 넘어온 데이터 관리를 간단하게 한다. 또한 공통된 서버 상태 관리로 팀작업을 원활히 한다.

 

마무리하며&다음에는...

fetch를 가지고 작업하다 서버측 데이터 상태관리까지 넘어가게 되었는데 많은 사람들이 fetch 대신 axios를 사용하고 있었다. fetch 에서 넘어오는 데이터의 에러 처리를 간단하게 해준다는 장점이 있어서 쓴다고 하는데, 한 번 가볍게 다뤄보는 것도 좋겠다.


 

자바스크립트에서 비동기를 제어하는 4가지 방법


자바스크립트에서 비동기를 제어하는 방법에는 총 "4가지"가 있다고 알려져 있다.

1) 콜백함수

2) 프로미스

3) async/await

4) 제너레이터

 

각각의 사용 방법 차이를 예제 코드와 함께 살펴보면 아래와 같다:

1) 콜백함수

function asyncFunction(callback) {
  setTimeout(() => {
    callback('Hello, world!');
  }, 1000);
}

asyncFunction((result) => {
  console.log(result);
});

2) 프로미스

function asyncFunction() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Hello, world!");
    }, 1000);
  });
}

asyncFunction()
  .then((result) => console.log(result))
  .catch((err) => console.log(err));

3) async/await

function asyncFunction() {
  return new Promise((resolve, resject) => {
    setTimeout(() => {
      resolve("Hello, world!");
    }, 1000);
  });
}

async function main() {
  try {
    const result = await asyncFunction();
    console.log(result);
  } catch (err) {
    console.log(err);
  }
}

main();

4) 제너레이터

function* asyncFunction() {
  yield new Promise((resolve) => {
    setTimeout(() => {
      resolve('Hello, world!');
    }, 1000);
  });
}

const gen = asyncFunction();
const promise = gen.next().value;

promise
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.error(error);
  });

 


문제 💡
아래와 같은 코드에서 콘솔에 찍히는 결과는 어떻게 나올까?


문제

console.log("start");

// 일반
console.log("1");

// Callback
setTimeout(() => {
  console.log("2");
}, 0);

// Promise
const promise = new Promise((resolve, reject) => {
  resolve("3");
});
promise.then((result) => console.log(result));

// async/await
const promise2 = new Promise((resolve) => {
  resolve("4");
});
async function test() {
  try {
    const res = await promise2;
    console.log(res);
  } catch (e) {
    console.log(e);
  }
}
test();

// Generator
function* generator() {
  yield "5";
}
const gen = generator();
console.log(gen.next().value);

console.log("end");

 

정답

start → 1(일반) → 5(제너레이터)→ end(일반) → 3(promise) → 4(async/await) → 2(callback)


해설 ✍️
이벤트 루프에 대해 알아보자

 


왜 정답이 이렇게 나왔는지 알려면 이벤트 루프(event loop)의 개념에 대해 알아야 한다.

이벤트 루프란 자바스크립트의 동시성을 관리하는 핵심 메커니즘이다. 

그 메커니즘을 간단하게 한 번, 자세히 한 번 이렇게 두 번으로 나누어 보며 이해해보자.

 

1) 자바스크립트 비동기 메소드의 브라우저 상 기본 동작 원리

자바스크립트에서 비동기 메소드는 기본적으로 아래와 같이 작동한다.

 

실행 콜스택(call stack)에서 이루어진다.

➡️ 시간이 오래 걸리는(비동기) 메소드의 경우 작업의 효율성을 위해 큐(queue)로 이동해 대기한다.

➡️ 다른 작업이 다 끝나면 다시 콜스택으로 호출 해 실행한다. 

https://blog.learncodeonline.in/javascript-event-loop,

 

 

2) 큐(queue)의 종류: 매크로 테스크 큐(Macrotask Queue)와 마이크로 테스크 큐(Microtask Queue)

좀 더 자세히 살펴보면, 사실 비동기 메소드들이 대기하는 큐(queue)는 작업 성질에 따라 여러 종류가 있다. 

마이크로 테스크 큐의 작업이 매크로 테스크 큐의 작업보다 앞서 처리되는 것을 확인할 수 있다.

 

대표적인 2가지는 매크로테스크 큐(Macrotask Queue, 일반적인 큐는 보통 이 매크로테스크 큐를 가리킨다) 마이크로테스크 큐(Microtask Queue)이다.

 

이러한 큐는 종류에 따라 실행 순서가 다르다.

예를 들어, 마이크로테스크 큐에 들어간 api는 매크로테스크 큐의 api보다 먼저 처리된다.

즉, 1️⃣ call stack ➡️ 2️⃣ Microtask Queue ➡️ 3️⃣ Macrotask Queue 순서로 처리된다.

 

api 종류에 따라 다른 큐에 배치되며 위에서 언급된 비동기 메소드들의 경우,

  • Promise, async/await ➡️ 마이크로 테스크 큐
  • setTimeout ➡️ 매크로 테스크 큐
  • Generator 의 경우, 비동기 기능을 가지지만 큐로 이동되지 않고 첫 콜 스택에서 바로 처리된다.

결론


위 내용을 요약하면 아래와 같다.

이벤트 루프란

큐에는 여러 종류가 있으며, 각 큐는 실행 순서가 있다.
마이크로 큐는 매크로 큐의 작업보다 우선적으로 실행된다.

자바스크립트에서 쓰이는 비동기 처리의 4가지 방법은 각각의 큐/스택을 거쳐 실행된다.
1) 콜백함수: call stack ➡️ Macrotask Queue ➡️ call stack 
2) 프로미스: call stack ➡️ Microtask Queue ➡️ call stack 
3) async/await: 프로미스 기반이므로 프로미스와 작동원리 같음.(즉, 마이크로 큐)
4) 제너레이터: call stack 에서 바로 처리. (비동기지만 Queue로 이동하지 않고 call stack에서 바로 처리됩니다.)

 

따라서 위 문제의 코드 동작 결과는 아래와 같다:

 1️⃣ call stack (start  1  5  end) ➡️ 2️⃣ Microtask Queue (3  4) ➡️ 3️⃣ Macrotask Queue (2)

 

 


Reference


 

1.

What the heck is the event loop | Philip Roberts | JSConf EU

 

 

2.

Microtasks and (Macro)tasks in Event Loop

https://medium.com/@saravanaeswari22/microtasks-and-macro-tasks-in-event-loop-7b408b2949e0

 

Microtasks and (Macro)tasks in Event Loop

JavaScript has a concurrency model based on an event loop, which is responsible for executing the code, collecting and processing events…

medium.com

 

 


Last Updated on 2024/07/02

 

 

 


Summary


이 글은 미션을 수행하며 학습했던 컴포넌트 분리 기준에 대한 내용을 정리한 것입니다. 컴포넌트 분리에 대한 절대적인 기준이 없어 여러 선행자분들의 의견을 참고하고자 아래 3가지 강의를 참고해 공통적인 부분을 거르고, 이후 개인적인 의견을 간단히 남기고자 하였습니다.

 

첫 두 강의의 공통점은, "컴포넌트를 쪼개는 절대적 기준 같은 것은 없다. 하지만 상대적으로 잘 쪼개는 컴포넌트 분리 기준은 존재한다."는 것이었는데요, 두 분 모두 '유지보수를 쉽게 하는'것에 기준을 두고 컴포넌트 분리를 설명하신 것이 인상 깊었습니다. 유지보수를 쉽게 하려면 어떻게 해야할까요? 데이터를 미래에 변할 수 있는 것과 상대적으로 잘 변하지 않을 데이터를 구분하는 것이었습니다.

 

조금 더 구체적으로는, 첫 강의자인 원지혁님은 컴포넌트의 구성을 스타일, 로직, 전역상태, 리모드 데이터 스키마(데이터)로 분리하셨고, 유지보수를 위해 변화가 가장 잘 일어나는 데이터 모델에 따라 컴포넌트 분리를 해주는 것을 추천하셨습니다. 두 번째 강의의 한재엽님은 변하는 것에 앞서 설명된 데이터를 데이터계산과 상호작용으로 나누셨습니다. 결국 둘 다 데이터 관리부분인데 처음부터 제공되는 데이터냐, 사용자의 액션이 있어야 계산되는 데이터냐에 따라 한 번 더 나누신듯하다. 변하는 부분을 데이터, 변하지 않는 부분을 UI로 잡고 분리한다는 점에서 원지혁님과 의견이 같았습니다. 두 강의자분 다 컴포넌트 분리의 기준을 유지보수로 잡고 계시고, 상대적으로 잘 변하는 것, 즉 컴포넌트가 다루는 데이터를 기준으로 컴포넌트를 분리하는 것을 제안하신다는 데에서 동일했습니다.

 

결론적으로, 저도 유지보수를 쉽게하는 컴포넌트 분리 기준으로 데이터 모델을 잡는 것에 설득되었습니다. 이 데이터를 변경가능성이 높은 데이터인지, 그렇지 않은 데이터인지 - 즉, 도메인 관련인지 아닌지로 나누는 방법이 좋다는 것도 이해하였습니다. 더하여 데이터는 훅스, UI관련은 컴포넌트, 상태관리는 store 등으로 나누는 기준에 동의합니다. 앞으로 리액트를 활용한 개발에 있어서 컴포넌트 분리는 이러한 기준으로 하고자 합니다. (하지만 컴포넌트 분리에 절대적인 기준이 없는 만큼 이 글을 보시는 분들의 다양한 의견에도 열려있습니다.)

 


강의

당근마켓, 원지혁 - 컴포넌트, 다시 생각하기


요약

  • 바라보기: React 컴포넌트의존성 - React 컴포넌트를 만드는데 필요한 것들은 어떤 것들이 있을까?
    • 기능적 분류(Type): props 와 hooks
    • 특징적 분류(Feature):
      • 스타일(컴포넌트의 스타일, src/components/Something.css)
      • 로직(UI조작에 필요한 커스텀 로직, src/components/Something.useInfinityScroll.ts),
      • 전역 상태(현재 UI를 표현하기 위해 유저의 액션을 통해 초래된 상태, src/store.ts)
      • 리모트 데이터 스키마(API 서버에서 내려주는 데이터의 모양, https://examples.com/api/v2/articles/json)
    • React 컴포넌트의 숨은 의존성
      • 한 컴포넌트에 정보를 추가한다고 하자. 그 컴포넌트에 새로운 props 를 추가하면 된다.
      • 하지만, 그게 끝이 아니다. 숨은 의존성이 존재한다. 타겟 컴포넌트와 루트 컴포넌트 사이의 props들의 추가 수정이 필연적일 수 밖에 없다. props drilling 없이 따로 store를 둔다해도 페이지 기반 라우팅을 한다면 결국 root 컴포넌트에 의존할 수 밖에 없을 것이다. 즉, 해당 컴포넌트에 새로운 정보(의존성)를 추가하기 위해서는 root 컴포넌트 수정도 필연적이다.
  • 함께 두기(co-locate): 이러한 의존성들을 어떻게 정리할 수 있을까?
    • 원칙1 - 비슷한 관심사라면 가까운 곳에(Keep Locality)
      • 한 컴포넌트에 필요한 스타일과 로직은 함께 두기 쉽다.
      • 전역상태는 여러 컴포넌트가 공유하고 있기 때문에 특정 컴포넌트와 함께 두기 어렵다.
      • 리모트 데이터 스키마 함께두기는 좀 더 복잡하므로 원칙2로 따로 분류해보자.
    • 원칙2 - 데이터를 ID 기반으로 정리하기 (Abstraction by Normalization)
      • 데이터 정규화(Normalization) 이라고도 한다.
        • yarn add mormalizer : 정규화를 도와주는 라이브러리
        • 하지만 여전히 숨은 의존성 문제는 존재한다: 사용 컴포넌트의 상위 컴포넌트에서 id 값을 정확히 받아와야 한다.
      • 글로벌 아이디(Global Id): 모델명을 따로 넘길 필요 없이 ID값만 가지고 특정 데이터를 유일하게 식별할 수 있도록 하는 체계.(상위 컴포넌트에서 ID값을 받아올 필요가 없다.)
        • 예시: 첫번째는 필요한 데이터를 바깥(./store.ts)에서 받아오고 있고, 두번째에서는 GlobalId를 이용해서 id 데이터를 같은 컴포넌트 안에 둘 수 있다! (16:02)
        • 도우미: 전역 객체 식별 (Global Object Identification, GOI): (17:02)
  • 이름 짓기(Naming): 프롭스 네이밍(Props Naming)에 대해서 생각해보자.
    • 원칙3 - 의존한다면 그대로 드러내기(Make Explicit)
      • 프로필 컴포넌트 - User 와 User가 의존하고 있는 Image, 이렇게 크게 2가지 데이터에 의존하고 있다. 2번째는 User와 Image의 의존성까지 보여주는 이름짓기를 활용해 더 직관적으로 의존성을 알 수 있다!
      • 하지만 위와 같이 한 컴포넌트에서 여러 모델의 정보(User 컴포넌트 내에 user 뿐 아니라 image 정보도 받아오고 있다.)를 표현하는 것은 일종의 관심사 분리가 제대로 안되었다는 일종의 신호이기도 하기에 아래와 같이 수정해주면 더욱 좋다.
    • 재사용하기(Reuse): 개발할 때 편리하기 위한 것보다 변경할 때 편리하기 위해 = 유지보수에 편하기 위해!
      • 변화하는 부분을 미리 예측하고 컴포넌트를 나누는게 중요하다.
        • 대부분의 변화는 리모트 데이터 스키마가 변화하는 방향을 따라서 움직인다.
        • 그렇다면 어떻게 변하는 것들과 변하지 않는 것들을 분리할 수 있을까?
    • 원칙4 - 데이터 모델 기준으로 컴포넌트 분리하기, Separating Components by Data model
      • 같은 모델을 의존하는 컴포넌트: 재사용
      • 다른 모델을 의존하는 같은 컴포넌트: 분리
  • 결론(Conclusion)
    • 원칙 요약
      • 원칙1 - 비슷한 관심사라면 가까운 곳에
      • 원칙2 - 데이터를 Id 기반으로 정리하기
      • 원칙3 - 의존한다면 그대로 드러내기
      • 원칙4 - 모델 기준으로 컴포넌트 분리하기
    • 추가
      • 이러한 위 4가지 원칙을 강제해서 리액트 클라이언트 개발을 더 편하게 해주는 GraphQL 데이터 레이어 프레임워크 Relay 를 소개해주시면서 강의를 마치셨다.

강의

토스슬래시, 한재엽: Effective Component 지속 가능한 성장과 컴포넌트


요약

  • 무엇이 변경될지 알았다면… 변경은 예측불가하다. 그래서 변경은 예측하지 말고 대응해야 한다.
  • 변경에 대응하기: 변경에 유연하게 대응하도록 컴포넌트를 나누기
    • 만들다보니 페이지가 커지고, 이 커진 코드를 ‘적당히’ 나누기 → ‘적당히’: 변경에 유연하게 대응하도록
    • 어떻게 하면 변경에 유연하게 대응하도록 컴포넌트를 분리할 수 있을까?
      1. Headless 기반의 추상화하기: 변하는 것 vs 상대적으로 변하지 않는 것
      2. 한 가지 역할만 하기: 또는 한가지 역할만 하는 컴포넌트의 조합으로 구성하기
      3. 도메인 분리하기: 도메인을 포함하는 컴포넌트와 그렇지 않은 컴포넌트 분리하기
  • Headless 기반의 추상화하기
    • 컴포넌트는 크게 3가지 역할을 한다:
      • 데이터 관리: 외부에서 받은 데이터, 상태과 같은 내부 데이터를 어떻게 관리하는지
      • UI 관리: 그러고 이러한 데이터를 어떻게 유저에게 보여줄지(UI)
      • 상호작용: UI를 기반으로 어떻게 사용자와 상호작용할지
    • 데이터와 UI의 분리. 컴포넌트를 구성하는데 필요한 데이터를 계산해야 하는데 이 역할을 use~훅스에 위임한다. 이러한 use~훅스는 UI를 관심사에서 제외하고 오직 데이터를 모듈화 하는데에만 집중할 수 있다.
    • UI와 상호작용 분리: 상호작용 부분도 역시 훅스로 만들어 관리한다.
  • 한 가지 역할만 하기(Composition)
  • 도메인 분리하기
    • UI패턴 공통화
  • 결론: 컴포넌트 분리시 고민해야 하는 것
    • 의도가 무엇인가? 분리하면 어떻게 좋아지는가?
    • 이 컴포넌트의 기능은 무엇인가?: 어떤 데이터를 다루는가?
    • 어떻게 표현되어야 하는가?

강의

당근마켓 원지혁 : 그래서 DECLARATIVE가 뭔데?


Declarative UI 패턴을 통해 자바스크립트를 마치 마크업짜듯 작성할 수 있게 되었다 → HTML, CSS / JavaScript 가 아니라 “관심사(정보)”를 기준으로 분리하게 되었다.

정보는 트리구조가 아니다! (함수형-순서형이 아니다!) 정보는 그래프 형태이다!

 

단일 진실 공급원 Single source of Truth

특정 상태가 변경되었을 때, 다른 부수효과를 최소로 하기 위해 단일 상태로 만들자. → ID 하나만 가지고 찾자. (Normalization) React가 UI에 대한 HOW를 제공한다면, GraphQL은 데이터에 대한 HOW를 제공한다.

 

선언적으로 데이터 요청하기 Declarative Data Fetching

리액트 컴포넌트는 데이터에 의존한다. 리액트는 어떤 정보(WHAT)가 필요한지만 요청한다. GraphQL 같은 라이브러리는 데이터를 어떻게(HOW)를 담당해 알아서 데이터를 처리해 넘겨준다. 결국 **선언적(Declarative)**란 공통된 HOW를 잘 분석해서 꺼내고 이를 통해 내 코드에 WHAT을 잘 뭉치는 과정. 이를 위해 HOW와 WHAT을 잘 가르는 안목이 필요하다.

 

+ Recent posts