많은 IT 회사의 채용 공고를 보면 테스트 코드 작성 유닛 테스트 코드, UI 테스트 코드 등 테스트 코드를 작성할 수 있는 개발자를 많이 원하고 있는 것 같습니다. 저는 모회사 과제 전형을 하면서 처음 접했는데요 빠르게 과제에 테스트를 적용하느라 왜 테스트 코드를 작성해야 하는가 어떤 테스트 코드가 좋은 테스트 코드인가에 대해서는 생각하지 않고 테스트 코드를 짰던 것 같습니다. 

 

 이후 리팩토링한 프로젝트에도 Jest와 React Testing Library를 이용해서 테스트 코드를 작성하고 있는데 테스트 코드를 작성하는 이유와 좋은 테스트 코드는 무엇인지 제대로 공부를 하고 작성해야 겠다는 생각에 이런 글을 작성하게 됐습니다.

회사의 테크 블로그와 구글링을 통해 공부한 내용을 정리해 보겠습니다.

 



테스트 코드는 무엇인가?

 먼저 테스트 코드는 소프트웨어의 기능과 동작을 테스트 하는 데 사용되는 코드입니다. 테스트 코드를 통해 어플리케이션의 결함을 빠르게 찾아내고 수정할 수 있습니다. 테스트 코드는 대표적으로 단위(유닛) 테스트, 통합 테스트, E2E(End to End) 테스트가 있고, 각각의 범위에 맞춰서 테스트 코드를 작성하고 개발 과정 중에 예상치 못한 문제를 미리 발견할 수 있고, 위에서 말씀드렸다시피 코드 수정이 필요한 상황에서 유연하고 안정적인 대응을 할 수 있습니다. 테스트 코드를 작성하는 이유에 대해 자세히 알아보며, 더 깊이 알아보겠습니다.

 

테스트 코드를 작성하는 이유는 무엇인가?

1. 디버깅 비용 절감

 실제로 자체 서비스 회사에서 서비스를 운영하고 유지 보수를 하는 과정 속에 개발자의 총 개발 소요 시간 중 온전히 요구사항에 대한 기능 개발을 하는 시간이 생각보다 많지 않다고 합니다. 

 

 즉 기능 개발 시간보다 어플리케이션에서 발생하는 버그에 대한 디버깅을 하는 시간이 더 많은 비중을 차지한다는 것인데요. 이 과정에서 테스트 코드가 없다면 문제 자체를 해결하는 것보다 어떤 코드 블록에서 문제가 발생했는지 찾는 과정에 더 많은 시간을 투자하게 됩니다. 이러한 테스트 코드를 매우 견고하게 작성하더라도 완벽하게 결함을 없앨 수 없는 것은 사실이지만, 테스트 코드를 작성함으로써 오류를 줄일 수 있고, 빠르게 대처할 수 있습니다.

 

 테스트는 위에서 언급했듯이 테스트하는 범위와 비중에 따라 세 분류로 나누어지고, 각각의 역할을 가지고 있습니다.

출처 : 인프랩 기술 블로그

  • 단위(Unit) 테스트 : 도메인 모델과 비즈니스 로직을 테스트합니다. 하나의 기능을 하는 코드를 테스트하며, 해당 코드가 의도한 대로 작동하는지 확인합니다. 일반적으로 Class나 Method를 테스트하는 범위로 정해지며, 가장 핵심적인 테스트라고 할 수 있습니다. 예를 들어 장바구니에 담는 기능을 테스트한다고 하면 특정 작업을 했을 때 의도한대로 상품이 담기는 지 테스트 합니다.
  • 통합(Intergration) 테스트 : 코드의 주요 흐름들을 통합적으로 테스트하며, 주요 외부 의존성(ex. DB)에 대해서 테스트합니다. 단위 테스트에서 검증된 개별 모듈들을 결합하여 그들이 예상대로 상호작용하고 있는지 확인합니다. 구체적으로 말씀드리자면 DB에 새로운 데이터가 추가됐을 경우 DB가 이를 올바르게 저장하고 프론트 단에서 데이터를 올바르게 표시하는지 확인합니다. Unit을 넘어 각기 다른 시스템이 잘 상호작용하는지 확인하는 작업이고, 더 많은 코드를 테스트 하기 때문에 에러 검출이 명확하지 않을 수 있습니다. 그렇기에 Unit 테스트에 초점을 두는 것이 좋다고 하네요.
  •  E2E 테스트 : 최종 사용자의 흐름에 대한 테스트이며, 외부로부터의 요청부터 응답까지 기능이 잘 동작하는지에 대해 테스트합니다. 즉 실제 사용자의 시나리오를 테스트하여 어플리케이션 자체가 의도한대로 동작하는지 확인합니다. 예를 들어 쇼핑몰 웹 어플리케이션에 대한 E2E 테스트를 작성한다고 했을 때, 고객이 상품을 선택하고, 결제한 후 배송받는 과정을 처음부터 끝까지 확인하는 것입니다.

이러한 테스트를 통해 결함을 최소화시켜 디버깅에 소모되는 시간을 줄여줌으로써 서비스 개발자가 비즈니스 개발에 집중할 수 있도록 하여 생산성을 향상시켜줍니다.

 

 

2. 코드 변경에 대한 불안감 해소

 버그를 고치는 과정에서 다른 버스가 발생하는 경우가 있습니다. 이를 회귀 버그라고 하는데요. 이는 굉장히 자주 발생하는 문제입니다. 어플리케이션에서 기능은 단일 하나의 요소로 이루어지지 않고, 여러 요소들이 서로 상호작용하고 협력하여 만들어지기 때문에 하나의 기능을 수정하는 과정에서 협력하고 있는 다른 기능에도 영향을 주면서 회귀 버그가 발생하게 됩니다. 

 

 이러한 회귀 버그완벽하게 차단하고 예방하는 것은 불가능하지만, 우리는 이를 관리하고 대처해야합니다. 즉, 회귀 테스트를 해야하는 것이죠. 회귀 테스트는 기능 추가나 오류 수정으로 인해 새롭게 유입되는 오류가 없는지 검증합니다. 여기서 우리가 알 수 있는 것은 테스트 코드는 그때 당시의 기능을 만들기 위해서만 필요한 코드가 아니라 코드를 작성한 이후에 변경된 요구사항을 적용하거나 코드를 리팩토링하는 과정에서도 필요합니다. 서비스가 지속 가능하게 발전하기 위해 필요한 코드인 것이죠. 우리가 코드를 작성할 때 이 부분을 고려하고 짜야한다고 생각합니다.

 

 

3. 더 나은 문서자료

 우리 개발자들은 좋은 코드를 작성하기 위해 가독성 좋은 코드를 작성하기 위해 노력합니다. 하지만 서비스 복잡도가 늘어나며 코드의 양이 많이지게 되고 자연스럽게 코드의 복잡도도 높아지게 됩니다. 이는 신규 입사자들이 코드를 이해하는데 많은 불편함을 겪게 합니다.(저도 빨리 신규 입사자가 되고 싶네요..ㅠㅠ TMI 죄송합니다..) 이를 대비하여 코드에 대한 문서화를 해두지만 코드처럼 문서를 유지 보수하지 않는 경우가 대다수이기 때문에 문서의 신뢰도는 시간이 지날수록 떨어지게 되어 바라던 효과를 내지 못합니다.

 

테스트 코드가 문서의 역할을 해준다면 위의 상황에서 발생하는 문제들을 모두 해결할 수 있습니다. 즉 테스트를 작성할 때 코드의 실제 동작을 기술함으로써 코드가 어떤 코드인지 이해할 수 있는 것이죠. 문서화 테스트를 통해 코드를 처음 접하는 사람들에게 기능과 코드를 이해할 수 있도록 도움을 줄 수 있습니다.

 

 

4. 좋은 코드는 테스트하기 쉽다.

 변경하기 쉬운 코드가 좋은 코드 중 하나라고 할 수 있습니다. 즉, 약한 결합도를 가지고 있는 코드를 좋은 코드라고 할 수 있으며, 강결합이 되어있는 코드는 유지비용이 증감되기에 좋지 못한 코드라고 할 수 있습니다.

 

 이러한 강결합으로 이루어진 코드는 테스트하기가 매우 어렵습니다. 외부의 영향을 받거나 내부적으로 의존성을 가지고 있기에 변경에 유연하게 대응하지 못하고 재사용하기도 어려운 코드입니다. 결론적으로 테스트 코드를 작성하면서 기존 강결합으로 이루어진 코드를 약한 결합도로 가진 코드로 변경할 수 있게 되기에 테스트 코드를 작성하며 자연스럽게 좋은 코드를 작성할 가능성이 높아지는 것입니다.

 

 테스트 코드를 작성하기 쉽다고 모두 좋은 코드는 아니지만 테스트하기 어려운 코드라면 좋지 못한 코드일 가능성이 매우 높습니다.

 

5. 테스트 코드를 작성함으로써 안정감 있는 프로젝트를 진행할 수 있다.

 코드를 실제 운영 환경에 배포했을 때 에러가 발생하지는 않을지 과연 자신이 작성한 코드가 정상적으로 운영이 되는지 생각하게 되면서 불안감에 빠지게 됩니다. 하지만 테스트 코드가 있다면 배포 전에 오류를 확인할 수 있기에 안정감 있는 프로젝트를 진행할 수 있고, 안정감 있는 우리는 볼 수 있습니다.

 

그리고 이는 자연스럽게 서비스 품질의 향상을 이끌어냅니다.

 



테스트 코드를 잘 작성하는 방법

 이제 이러한 테스트 코드를 어떻게하면 잘 작성할 수 있는지 인프랩 블로그의 내용들을 정리하며 알아봅시다.

1. 테스트 코드는 DRY 보다는 DAMP 하게 작성하라.

 개발 원칙 중 DRY(Don't Repeat Yourself) 원칙에 대해 아시나요? 개발자인 우리는 중복 코드를 싫어하기에 중복 코드를 최대한 없애려고 노력합니다. 하지만 중복을 줄이기 전에 DAMP(Descriptive and Meaningful Phrases) 하게 테스트 코드를 작성하는 것을 고려해야 합니다. DAMP 원칙은 의미있고 설명적인 구문을 사용하라는 원칙입니다. 

 

 즉, 테스트 코드의 중복을 줄이기 위해 테스트 간의 결합도를 높이는 것은 좋지 않은 방식이라는 것입니다. 여기서 중요한 점은 테스트는 서로 독립적이고 격리되어야 한다는 것입니다. 테스트가 서로 독립적이지 않다면, 테스트 코드를 수정하는 과정에서 다른 테스트의 구조까지 확인해야하는 불편함이 생길 것이고, 테스트에 대한 신뢰도도 떨어지게 됩니다.

DAMP 원칙을 지키면서 중복을 줄이는 방법은 테스트 픽스쳐 함수나 클래스 등을 사용하는 방식이 있습니다.

 

 

2. 테스트는 구현이 아닌 결과를 검증하도록 한다.

 테스트 코드를 작성하는 과정에서 빠른 테스트를 작성하기 위해 모의 객체를 사용하는 경우가 있습니다. 모의 객체로 mock, spy, stub 등을 사용하는 경우 테스트 대상의 구현을 알아야만 테스트를 작성할 수 있는데요. 그런데 이 부분을 잘못 사용하여 테스트를 작성하는 경우가 종종 있습니다.

인프래랩 블로그의 예시를 톻해 이 경우를 알아봅시다.

export class JobApplicant {
  updatePass() {
    this.validateIsNotCancel();
    this.validateIsNotFail();
    // 유효성 검증 통과후 상태 변경 
  }

  validateIsNotFail(): void {
    // 검증
  }

  validateIsNotCancel(): void {
    // 검증 
  }
}

위와 같이 지원자를 합격 상태로 변경하기 전에 지원자가 현재 불합격이거나 취소 상태가 아닌지에 대한 조건이 있다고 가정해봅시다. 해당 코드를 jest의 spyOn 메소드를 통해 작성해보면 아래와 같습니다.

it('지원자를 합격시킨다', () => {
	jobApplicant = new JobApplicant
    
    jest.spyOn(jobApplicant, 'validateIsNotCancel').mockReturnValue(undefined);
    jest.spyOn(jobApplicant, 'validateIsNotFail').mockReturnValue(undefined);
    
    jobApplicant.updatePass();
    
    expect(joApplicant.validateIsNotCancel).toBeCalledTimes(1);
    expect(joApplicant.validateIsNotFail).toBeCalledTimes(1);
    expect(joApplicant.status).toBe(JobApplicantStatus.PASS);
    
});

위 테스트 코드처럼 상태 변경에 대한 검증 뿐만 아니라 Cancel과 Fail에 대한 메소드가 호출되었는지 검증을 해서 더 완벽한 테스트라고 생각할 수 있지만, 이 테스트는 깨지기 쉬우며 좋은 테스트가 아닙니다.

만약 Cancel에 대한 메소드의 네이밍이 변경되거나 Fail에 대한 메소드가 삭제되면 이 테스트는 깨집니다. 즉 구현에 의존적이며 테스트의 목적이 구현에 맞춰져 있는 테스트인 것이죠. 내부 구현이나 비공개 메소드들은 언제든지 바뀔 여지가 있는 코드이기 때문에 정보의 은닉을 위해 숨깁니다. 이를 굳이 꺼내서 테스트 하는 것은 좋지 않습니다.

 

테스트 코드는 내부 구현보다는 실행 결과에 집중하는 것이 리팩토링 내성을 높일 수 있습니다. 그리고 호출되었는지 검증하는 부분 또한 의미 없는 검증입니다. 이 테스트의 검증 목적은 합격 상태 변경에 대한 검증으로도 충분합니다. 호출에 대한 검증을 진행하며 오히려 테스트의 목적을 흐리게 하고 가독성을 떨어뜨립니다.

 

우리는 어떻게 라는 내부 구현 검증보다는 무엇을 이라는 결과 검증에 집중하여 테스트 코드를 작성해야합니다.

 

옳은 테스트 코드 ▼

it ('지원서를 합격시킨다', () => {
	const jobApplicant = new JobApplicant();
    
    jobApplicant.updatePass();
    
    expect(jobApplicant.status).toEqual(JobApplicantStatus.PASS);

});

it.each([
	['취소', JobApplicantStatus.CANCLE],
    ['불합격', JobApplicantStatus.FAIL],
])(
	'%s 상태의 지원서는 합격시킬 수 없습니다.'
    (_, status) => {
    	const jobApplicant = new JobApplicant();
        jobApplicant.status = staus;
        
        expect(() => jobApplicant.updatePass()).toThrowError();
    },
);

 

 

3. 읽기 좋은 테스트를 작성하라.

테스트 코드는 메인 , 제품 코드를 위한 코드지만 가독성도 좋아야합니다. 불명확한 테스트 코드는 읽는 행위도 유지보수 하는 행위도 어렵게 만듭니다.

 

좋은 테스트 코드는 읽는 사람 입장에서 이 테스트를 이해하는 데 필요한 모든 정보를 테스트 케이스 본문에 담고 있어야합니다. 동시에 관련 없는 정보는 담지 말아야 합니다.

 

이렇게 가독성이 높은 코드를 작성하기 위한 방법 중 하나는 테스트의 구조를 잘 잡는 것입니다.

 

테스트 구조는 준비, 실행, 검증 3개의 구절로 나위어질 수 있습니다. 각각의 구절을 AAA 패턴 (Arrange-Act-Assert 주석으로 구분)이나 GWT 패턴(given-when-then 행위 주석으로 구분)으로 구간을 나누어 작성하는 것이 좋습니다. 

 

그리고 테스트 안에 너무 많은 양의 코드가 있는 경우 재사용의 목적으로 모듈화(테스트 팩토리, 빌더, 헬처 메소드)해두면 쉽게 읽힐 수 있습니다. 모듈화에 대한 내용은 이후 학습하고 정리하여 올리겠습니다.

 

 

4. 테스트 명세에 비즈니스 행위를 담자.

 테스트명을 작성할 때 명확한 의도가 들어나도록 작성해야 합니다. 테스트 코드는 코드를 처음보는 개발자 입장에서 코드를 설명하는 문서가 될 수 있기에 명확한 의도가 담긴 명세를 작성함으로써 코드에 대해 이해하기 쉬도록 유도합니다.

 

 그리고 명확하게 의도가 들어나는 이름을 적기 위해선 개발자 용어가 아닌 비즈니스 행위가 담긴 이름을 작성하여 비개발자도 읽을 수 있도록 설명되어야 합니다.

 

 

[요약]

  • 테스트 코드의 중복을 줄이는 것이 아니라 서술적이고 의미있게 작성해야한다.
  • 테스트는 서로 독립적이고 격리되게 구현하여 테스트 수정 과정에 다른 테스트가 영향을 받지 않도록 해야한다.
  • DAMP 원칙을 지키면서 중복을 줄이는 방법 중 하나는 테스트 픽스펴 함수나 클래스 등을 사용하는 것이다.
  • 어떻게 하는 내부 구현 검증보다는 무엇을 이라는 결과 검증에 집중하여 테스트 코드를 작성해야 한다.
  • 테스트 코드도 관리의 대상이기에 읽기 좋은 코드로 작성해야 한다. 이를 위해 테스트 구조를 잘 잡아서 작성한다.
  • 테스트 안에 너무 많은 양의 코드가 있다면 코드 재사용의 목적을 모듈화함으로써 쉽게 읽힐 수 있도록 하자.
  • 테스트 명세에 비개발자도 이해하기 쉽도록 비즈니스 행위를 담자.


 

이상입니다. 저 또한 테스트 코드를 작성함으로써 지속 가능하 서비스 및 프로젝트를 만들 수 있다고 생각합니다. 만약 시간이 촉박한 경우 테스트 코드 작성으로 인해 오버 엔지니어링으로 이어질 수도 있는데 이 경우 핵심 로직 위주로 테스트 코드를 작성하여 일정을 잘 맞출 수 있도록 해야합니다. 이 글을 읽는 여러분도 테스트 코드 작성을 고려하고 적용해 보면 좋을 것 같습니다 :) 

 

 

 

정리하기 위해 참고한 사이트 및 블로그 :

 

테스트 코드를 왜 그리고 어떻게 작성해야 할까?

테스트 코드가 필요한 이유와 잘 작성하는 방법에 대해 공유합니다.

tech.inflab.com

 

테스트 코드의 필요성

안녕하세요.

www.startupcode.kr

 

프로젝트를 완료한 후 Jest로 주요 기능에 대한 테스트 코드를 작성하여 테스트 커버리지를 높이고자 노력하고 있습니다. 이 과정에서 OpenWeatherMap API와 Kakao 지도 API를 이용해 날씨 데이터와 위치 데이터를 가져와 전역을 데이터를 관리해주는 Provider에 대한 테스트 코드를 작성하며 어떻게 하면 효과적인 테스트 코드를 작성할 수 있을지 고민했습니다.

 

처음에는 각 API의 쿼리스트링으로 들어가는 경도와 위도를 고정시키고 현시각에 기반하여 먼저 각 API를 이용해 데이터를 불러온 후에 날씨 Provider를 실행시켜서 처음에 불러온 데이터와 일치하는지 테스트 하는 코드를 작성해 볼려고 했습니다. 하지만 테스트 코드에 대해 알아보면서 이런 방식은 외부 의존성을 갖는 코드를 이용해 테스트를 진행하는 것이기 때문에 테스트의 독립성과 신뢰성을 떨어뜨리는 옳지 못한 방식이라는 것을 이해했습니다. 그래서 의존성을 갖는 부분을 모킹( Mocking )하여 날씨 Provider가 모킹한 데이터를 잘 받고 하위 컴포넌트로 잘 전달하는지 테스트 하는 방식으로 코드를 작성하기로 결정했습니다.

 

이러한 효과적인 테스트 코드 작성을 위한 규칙에 대한 내용은 이 글 이후에 따로 정리하여 올리겠습니다. 우선은 이 과정에서 참고한 우하한 기술블로그 링크와 Chat GPT 4o가 알려준 내용을 간단하게 적고 넘어가겠습니다.

 

코드와 함께 살펴보는 프론트엔드 단위 테스트 – Part 1. 이론 편 | 우아한형제들 기술블로그

{{item.name}} “테스트 코드 작성은 파일을 만들어서 한 글자 쓰기 전까지 엄청 귀찮다.” from 언젠가 내가 데일리 스크럼에서 한 말 개발자들에게 테스트 코드 작성은 해야 하지만 손이 잘 가지 않

techblog.woowahan.com

ChatGPT 4o의 답변

1. 단일 책임 원칙
각 테스트는 단일한 동작을 검증해야 합니다. 
즉, 하나의 테스트 케이스는 하나의 기능만 테스트해야 합니다.


2. AAA 패턴 (Arrange, Act, Assert)
테스트 코드는 일반적으로 세 단계로 구성됩니다.
- Arrange : 테스트에 필요한 데이터를 준비하고 설정합니다.
- Act: 테스트하려는 동작을 수행합니다.
- Assert: 동작의 결과를 검증합니다.


3. 독립적인 테스트
테스트는 서로 독립적이어야 하며, 하나의 테스트가 다른 테스트에 영향을 주어서는 안 됩니다. 
테스트 간에 상태가 공유되지 않도록 해야 합니다.


4. 명확하고 설명적인 테스트 이름
테스트 이름은 테스트의 목적을 명확하게 설명해야 합니다. 
이는 나중에 테스트 실패 시 문제를 쉽게 파악하는 데 도움이 됩니다.


5. 테스트 커버리지
가능한 많은 코드를 테스트하려고 노력하세요. 
중요한 로직, 경계 조건, 예외 상황 등을 포함해 다양한 시나리오를 테스트합니다.


6. 적절한 Mocking
외부 의존성을 갖는 코드(예: API 호출, 데이터베이스 접근 등)는 Mocking을 사용하여 테스트합니다. 
이를 통해 테스트의 독립성과 신뢰성을 높일 수 있습니다.


7. 빠른 피드백 루프 (Fast Feedback Loop)
테스트는 빠르게 실행되어야 합니다. 
느린 테스트는 개발자의 피드백 루프를 방해하므로, 가능한 한 테스트 속도를 최적화합니다.


8. 지속적인 통합 (Continuous Integration)
테스트는 지속적인 통합(Continuous Integration, CI) 파이프라인에서 자동으로 실행되어야 합니다. 
이를 통해 코드 변경 시마다 자동으로 테스트가 실행되어 문제를 조기에 발견할 수 있습니다.


9. 경계 값 분석 (Boundary Value Analysis)
경계 값과 같은 중요한 값들을 테스트합니다. 
이는 예기치 않은 버그를 발견하는 데 도움이 됩니다.


10. 테스트 유지보수
테스트 코드는 애플리케이션 코드와 동일한 수준의 유지보수가 필요합니다. 
코드 변경 시 테스트 코드도 함께 업데이트되어야 합니다.

 

 

모킹(Mocking) 이란?

단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)으로 대체하는 기법

이러한 모킹 기법은 임의로 객체를 만들고 데이터를 넣어주는 방식으로 할 수 있습니다.

jest.spyOn(window, "fetch").mockImplementation(() => {
	return Promise.resolve({
			json: () => Promise.resolve([
					{ id: 1, title: "Blog 1", author: "Author 1" },
					{ id: 2, title: "Blog 2", author: "Author 2" },
					{ id: 3, title: "Blog 3", author: "Author 3" },
			])
	})
});

하지만 위와 같은 방식은 추후에 호출 방식이 바뀔 때마다 테스트를 하나하나 바꿔줘야 하기 때문에 좋은 방식이라고 하기는 어렵습니다. 이러한 문제를 해결할 수 있는 방법이 MSW를 이용해 API 요청을 모킹하는 방법입니다.

 

 MSW(Mock Service Worker) 란?

프론트엔드 개발에서 API 요청을 모킹할 수 있는 라이브러리입니다. Fetch API와 XHR을 사용한 네트워크 요청을 가로채 요청된 URL 및 HTTP 메소드에 맞추어 모킹된 응답을 제공할 수 있습니다. 이를 이용해 외부에 의존하는 실제 API가 아닌 모킹한 API를 통해 날씨 Provider를 테스트 해보는 코드를 작성할 예정입니다.

 

기초적인 사용법은 따로 정리하여 올리겠습니다. 지금은 프로젝트에 직접 적용해보고 어떤 방식으로 적용했는지에 대해 정리하겠습니다. 이를 통해서 외부 API를 이용하는 코드에 대한 테스트 코드를 작성하는 방법을 함께 알아봅시다.


Next.js + TypeScript 프로젝트에 Jest 테스트 환경 설정

먼저 Next.js + TypeScript에서 테스트 코드를 실행할 수 있는 환경을 설정해 줍시다. Jest와 React Testing Library를 사용하여 테스트 코드를 작성할 것입니다.

 

1. 필요한 패키지를 설치해 줍니다. (-D는 개발 환경에서만 사용하겠다는 의미입니다. --save-dev를 적어도 됩니다.)

npm i -D 
jest
jest-environment-jsdom
@testing-library/jest-dom 
@testing-library/react 
@testing-library/user-event 
@types/jest 
ts-jest
ts-node
msw

 

2. package.json scripts에 test 추가

// package.json

"scripts": {
    "test": "jest",
    "test:watch": "jest --watchAll",
    "coverage": "jest --coverage"
},

 

2. jest.config.ts와 jest.setup.ts 파일 루트 디렉토리에 추가

// jest.config.ts
import type { Config } from 'jest'
import nextJest from 'next/jest.js'

const createJestConfig = nextJest({
  dir: './',
})

const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  transformIgnorePatterns: [
    '/node_modules/',
    '^.+\\.module\\.(css|sass|scss)$',
  ],
  // 절대 경로 사용시
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}

export default createJestConfig(config)
// jest.setup.ts

import '@testing-library/react'
import '@testing-library/jest-dom'

 

3. TypeScript가 jest 코드들을 인식할 수 있도록 타입 추가

// tsconfig.json
"compilerOptions": {
    "typeRoots": ["./types", "./node_modules/@types"],
    "types": ["jest", "node"],
}

 

이렇게 설정을 끝내고 msw를 이용해 테스트 코드를 작성해 보겠습니다.

 


날씨 Provider에 대한 테스트 코드 작성

테스트 코드를 작성할 날씨 Provider 코드는 다음과 같습니다. (완전 초짜 개발자라 미흡한 부분이 많을 수 있습니다. 코드리뷰는 언제나 환영입니다.)

'use client'
import {
  Dispatch,
  ReactNode,
  SetStateAction,
  createContext,
  useEffect,
  useState,
} from 'react'

type WeatherType =
  | 'Clear'
  | 'Rain'
  | 'Thunderstorm'
  | 'Snow'
  | 'Mist'
  | 'Drizzle'
  | 'Clouds'
  | 'Fog'
  | 'Haze'
  | 'Sand'

interface WeatherProvider {
  icon: string | null
  tempNow: number | null
  tempMax: number | null
  tempMin: number | null
  address: string | null
  weather: WeatherType | null
  setTempMax: Dispatch<SetStateAction<number | undefined>>
  setTempMin: Dispatch<SetStateAction<number | undefined>>
}

export const WeatherContext = createContext<WeatherProvider>({
  icon: null,
  tempMax: null,
  tempMin: null,
  tempNow: null,
  address: null,
  weather: null,
  setTempMax: () => {},
  setTempMin: () => {},
})

export const WeatherProviderByContext = ({
  children,
}: {
  children: ReactNode
}) => {
  const [temperature, setTemp] = useState<number>()
  const [temperatureMin, setTempMin] = useState<number>()
  const [temperatureMax, setTempMax] = useState<number>()
  const [weatherIcon, setIcon] = useState<string>()
  const [latitude_state, setLatitude] = useState<number>()
  const [longitude_state, setLongitude] = useState<number>()
  const [address, setAddress] = useState<string>()
  const [weather, setWeather] = useState<WeatherType>()

  // API Keys
  // openweathermap
  const API_KEY: string = ''
  // KaKao
  const KAKAO_API_KEY: string = ''

  useEffect(() => {
    const getLocation = async () => {
      try {
        // 위치 노출이 허용되어 있는지 확인하고 현재 위치 가져오기
        const position = await new Promise<GeolocationPosition>(
          (resolve, reject) => {
            navigator.geolocation.getCurrentPosition(resolve, reject)
          },
        )

        // 위도와 경도 가져오기
        setLatitude(position.coords.latitude)
        setLongitude(position.coords.longitude)
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    getLocation()
  }, [])

  useEffect(() => {
    const getWeather = async () => {
      try {
        // OpenWeatherMap에서 위치 기반 날씨 정보 불러오기
        const weatherResponse = await fetch(
          `https://api.openweathermap.org/data/2.5/weather?lat=${latitude_state}&lon=${longitude_state}&appid=${API_KEY}&units=metric`,
        )
        const weatherData = await weatherResponse.json()

        // 날씨 정보 저장하기
        setIcon(weatherData.weather[0].icon)
        setWeather(weatherData.weather[0].main)
        setTemp(weatherData.main.temp.toFixed(1))
        setTempMax(weatherData.main.temp_max.toFixed(1))
        setTempMin(weatherData.main.temp_min.toFixed(1))
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    if (latitude_state && longitude_state) getWeather()
  }, [latitude_state, longitude_state])

  useEffect(() => {
    const getAddress = async () => {
      try {
        // Kakao API에서 위치에 대한 주소 가져오기
        const addressResponse = await fetch(
          `https://dapi.kakao.com/v2/local/geo/coord2address.json?x=${longitude_state}&y=${latitude_state}`,
          {
            method: 'GET',
            headers: { Authorization: `KakaoAK ${KAKAO_API_KEY}` },
          },
        )
        const addressData: LocationResponse = await addressResponse.json()
        
        // 값을 제대로 불러왔다면 주소 state에 넣기
        if (addressData.documents && addressData.documents.length > 0) {
          setAddress(
            addressData.documents[0].address.region_1depth_name +
              ' ' +
              addressData.documents[0].address.region_2depth_name,
          )
        }
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    getAddress()
  }, [temperatureMin])

  return (
    <>
      {weatherIcon &&
        temperature &&
        temperatureMax &&
        weather &&
        temperatureMin &&
        address && (
          <WeatherContext.Provider
            value={{
              icon: weatherIcon,
              weather: weather,
              address: address,
              tempMax: temperatureMax,
              tempMin: temperatureMin,
              tempNow: temperature,
              setTempMax,
              setTempMin,
            }}>
            {children}
          </WeatherContext.Provider>
        )}
    </>
  )
}

 

위의 Provider를 가지고 와서 모킹한 API 요청에 대한 데이터를 보내줬을 때 잘 받아서 뿌려주는 지에 대한 테스트 코드를 작성해 보겠습니다.


MSW API 모킹하기

1. msw handlers 설정하기

msw handlers에 원하는 요청 경로와 메서드, 응답값 등을 작성하면 됩니다.

저는 위에서 말씀 드렸다시피 OpenWeatherMap API와 Kakao 지도 API 응답을 모킹하겠습니다.

이전에는 rest를 가지고 와서 사용하는 방식이었는데 msw 공식문서를 보면 지금은 http와 HttpResponse를 가져와 API를 모킹해야 합니다. 더 자세한 내용은 아래 공식 문서를 참고해 주세요.

 

Getting started

Three steps to get started with Mock Service Worker.

mswjs.io

 

1.x → 2.x

Migration guidelines for version 2.0.

mswjs.io

 

 

Provider에서 받고 보내주는 데이터는 다음과 같습니다.

날씨 데이터 :

    setIcon(weatherData.weather[0].icon)
    setWeather(weatherData.weather[0].main)
    setTemp(weatherData.main.temp.toFixed(1))
    setTempMax(weatherData.main.temp_max.toFixed(1))
    setTempMin(weatherData.main.temp_min.toFixed(1))

 

위치 데이터 :

setAddress(
            addressData.documents[0].address.region_1depth_name +
              ' ' +
              addressData.documents[0].address.region_2depth_name,
          )

 

API를 모킹하여 해당하는 응답을 보내주면 됩니다.

// src/mocks/handlers.ts

import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://api.openweathermap.org/data/2.5/weather', () => {
    return HttpResponse.json({
      // 실제 데이터는 배열을 보내주기 때문에 [{}] 형대로 목데이터 설정
      weather: [{ icon: '01d', main: 'Clear' }],
      // 실제 데이터는 객체를 보내주기 때문에 {} 형태로 목데이터 설정
      main: { temp: 23.5, temp_min: 20.7, temp_max: 26.2 },
    });
  }),

  http.get('https://dapi.kakao.com/v2/local/geo/coord2address', () => {
    return HttpResponse.json({
      documents: [
        {
          address: {
            region_1depth_name: '서울',
            region_2depth_name: '관악구',
          },
        },
      ],
    });
  }),
];

 

 

2. msw 서버 설정하기

// scr/mocks/server.ts

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

 

jest.setup.ts에서 서버 설정 (모든 테스트가 msw API를 이용하는 경우)

// jest.setup.ts

import '@testing-library/react';
import '@testing-library/jest-dom';
import { server } from './src/mocks/server';

beforeAll(() => server.listen());

afterEach(() => server.resetHandlers());

afterAll(() => server.close());

beforeAll을 통해 모든 테스트가 시작되기 전에 msw를 시작해주고, afterEach를 통해 테스트가 끝난 후 서버를 리셋해줘 서로에게 영향을 주지 못하게 하여 테스트 독립성을 지키고 테스트가 끝난 후 afterAll로 msw 서버를 종료시켜 줍니다.

 

테스트 전체적으로 msw 서버를 이용해 모킹한 API를 사용해야 한다면 위와 같이 jest.setup.ts에 설정을 해주는 게 좋습니다. 그게 아니라면 테스트 개별적으로 서버를 열어주는 것이 더 나은 것 같습니다. 다른 테스트는 msw 서버를 이용하지 않는데 굳이 setup 파일에 설정하여 모든 테스트에 적용할 필요는 없다고 생각합니다.


테스트 코드 작성하기

이제 Provider에 대한 테스트 코드를 작성해 봅시다.

import { useContext } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { WeatherProviderByContext, WeatherContext } from './WeatherContext'
import { server } from '../src/mocks/server'


// 테스트할 때 msw 이용하기
beforeAll(() => server.listen())

afterEach(() => server.resetHandlers())

afterAll(() => server.close())

// 테스트용 컴포넌트 만들기
const TestComponent = () => {
  const { icon, tempNow, tempMax, tempMin, address, weather } =
    useContext(WeatherContext)

  return (
    <>
      <div data-testid="icon">{icon}</div>
      <div data-testid="tempNow">{tempNow}</div>
      <div data-testid="tempMax">{tempMax}</div>
      <div data-testid="tempMin">{tempMin}</div>
      <div data-testid="address">{address}</div>
      <div data-testid="weather">{weather}</div>
    </>
  )
}

test('WeatherContext를 통해 불러온 데이터를 하위 컴포넌트로 잘 보내주는 지 테스트', async () => {
  render(
    <WeatherProviderByContext>
      <TestComponent />
    </WeatherProviderByContext>,
  )

  await waitFor(() => {
    expect(screen.getByTestId('icon')).toHaveTextContent('01d')
    expect(screen.getByTestId('tempNow')).toHaveTextContent('23.5')
    expect(screen.getByTestId('tempMax')).toHaveTextContent('26.2')
    expect(screen.getByTestId('tempMin')).toHaveTextContent('20.7')
    expect(screen.getByTestId('address')).toHaveTextContent('서울 관악구')
    expect(screen.getByTestId('weather')).toHaveTextContent('Clear')
  })
})

 

테스트를 시작할 떄 msw 서버를 켜줘서 API를 모킹해주고 일전에 설정해 줬던 데이터를 잘 전달해주는지 테스트 하는 코드입니다.


테스트 진행 중 발생한 에러 해결 과정

1번째 에러 : cannot find module 'msw/node'

해당 테스트 코드를 테스트 하는 과정에서 msw/node를 찾지 못한다는 오류가 발생할 수도 있는데 이럴 경우 jest.config.ts 팔일에 아래의 코드를 추가해주면 됩니다.

// jest.config.ts
module.exports = {
  testEnvironmentOptions: {
    customExportConditions: [''],
  },
}

 

이 설정은 JSDOM이 msw/node 모듈을 가져올 때 기본 내보내기 조건을 사용하도록 강제합니다. 이를 통해 올바른 모듈을 가져와서 테스트 환경에서 사용할 수 있게 됩니다. 즉, 이 설정을 적용하면 msw/node 모듈을 제대로 가져올 수 있도록 도와줍니다.

 

 

2번째 에러 : ReferenceError: TextEncoder is not defined

msw/node를 찾지 못하는 문제는 해결했지만 계속해서 TestEncoder를 찾지 못하는 문제가 발생합니다. 찾아본 결과 공식 문서에서 해결 방법을 알려주고 있는 것을 알아냈습니다.

 

1.x → 2.x

Migration guidelines for version 2.0.

mswjs.io

루트 디렉토리에 jest.polyfills.ts 파일을 만들어 TestEncoder와 TestDecoder를 정의해주면 됩니다.

// jest.polyfills.ts

import { TextDecoder, TextEncoder } from 'node:util';

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
});

이후 jest.config.ts에서 파일을 사용한다고 명시해 줍시다.

module.exports = {
  setupFiles: ['./jest.polyfills.js'],
}

 

 

3번째 에러 : ReferenceError: Response is not defined

이렇게 정의 해주면 이제 Response를 찾을 수 없다고 나오네요.. 공식 문서에  있는 모든 설정을 적용해 주는게 좋을 것 같습니다.

 

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')
 
Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

위와 같이 정의해줘야 하기 때문에 undici를 설치해 줍니다.

https://github.com/nodejs/undici

 

아래와 같이 import 상태로 변환하였더니

// jest.ployfills.ts

import { TextDecoder, TextEncoder } from 'node:util'
import { Blob, File } from 'node:buffer'
import { fetch, Headers, FormData, Request, Response } from 'undici'

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

아예 TestEncoder도 찾지 못하더라고요.. 그래서 공식 문서 그대로 하기 위해서 js 파일로 바꾸고 다음과 같이 코드를 작성했습니다.

// jest.ployfills.js

const { TextDecoder, TextEncoder } = require('node:util')

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
})

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')

Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

 

 

4번째 에러 : ReadableStream is not defined

이 에러는 node:util에서 ReadableStream을 가져와 정의해주면 해결됩니다.

// jest.polyfills.js

const { TextDecoder, TextEncoder } = require('node:util')
const { ReadableStream, TransformStream } = require('node:stream/web')

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
  ReadableStream: { value: ReadableStream },
  TransformStream: { value: TransformStream },
})

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')

Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

 

 

5번째 에러 :  Error getting location: TypeError: Cannot read properties of undefined

생각해 보니까 OpenWeatherMap API랑 Kakao 지도 API에 대한 요청만 모킹해주고, geolocation에 대한 모킹은 안 해줬더라고요..하하 이에 대한 모킹도 해보겠습니다.

 

beforeAll에 브라우저 API에 대한 모킹을 해놓으면 됩니다.

// WeatherContext.test.ts

beforeAll(() => {
  server.listen()
  // getCurrentPosition 모킹
  Object.defineProperty(global.navigator, 'geolocation', {
    value: {
      getCurrentPosition: jest.fn().mockImplementation(callback =>
        callback({
          coords: {
            latitude: 37.514575,
            longitude: 127.0495556,
          },
        }),
      ),
    },
  })
})

 

이렇게 하면 테스트를 진행 시 getCurrentPosition 함수를 사용할 때 설정한 coords 데이터를 보내줍니다.

 

긴 글 읽어주셔서 감사합니다.

6. useCallback

useMemo와 같이 메모이제이션 기법 중 하나이며 컴포넌트의 성능을 최적화하기 위한 도구 중 하나입니다.

useCallback은 함수의 반환 값을 메모이제이션 하는 것(useMemo)이 아니라 함수 자체를 캐싱해주는 Hook입니다.

 

함수형 컴포넌트는 함수이며 이러한 함수형 컴포넌트를 렌더링 한다는 것은 컴포넌트 함수를 호출하는 것입니다. 그렇기 때문에 내부 변수들은 렌더링이 될때마다 초기화됩니다. 이 과정에서 useCallback으로 원하는 함수를 묶어서 렌더링이 다시 되어도 초기화 되는 것을 막을 수 있습니다. 여기서 주의할 점은 useCallback으로 함수를 메모이제이션 할려면 해당 함수를 작성할 때 함수 표현식으로 작성해야 한다는 것 입니다. 만약에 함수 선언식으로 함수를 작성한다면 호이스팅되기 때문에 메모이제이션 할 수 없습니다.

 

useCallback의 구조 

useCallback(() => {
	// 메모이제이션 해 줄 콜백함수
},[의존성 배열])

// 사용 예

const functionName = useCallback((num) => {
	return num + ;1
}, [state])

useMemo와 동일하게 의존성 배열 내부에 있는 값이 변경되지 않는 이상 다시 초기화 되지 않습니다.

 

예제를 사용해보며 이해해 봅시다.

import {useState} from 'react';


export default function App () {

	const [val, setVal] = useState(100)
    const [isDark, setIsDark] = useState(false)
    
    const boxstyle = () => {
    	return (
        	backgroundColor: 'pink',
            width: `${val}px`,
            height: `${val}px`,
        )
    }



	return (
    	<div style={{
        	background : isDark ? "black" : "white"
        }}>
    		<input 
            	type = 'number'
                value = {val}
                onChange = { (e) => {setVal(e.target.value)} }
            />
            <button onClick={setIsDark(!isDark)}>
            	{isDark ? 다크 모드 : 화이트 모드}
            </button>
            <Box createBoxStyle={boxstyle} />
    	</div>
    )
}
import {useEffect, useState} = form 'react'

export default const Box({ createBoxStyle }) {
	const [style, setStyle] = useState({});
    
    useEffect(()=>{
    	console.log("박스 크기");
        setStyle(createBoxStyle());
    },[createBoxStyle]);
    
    return <div style={style} />
}

 

위 코드 같은 경우 state가 바뀌면서 재 렌더링 되는 경우 boxstyle 함수가 초기화 되기 때문에 isDark가 변경되어도 "박스 크기"가 콘솔에 뜨게 됩니다. 박스 크기와 직접적인 연관이 없는 isDark가 변경딜 때는 Box 컴포넌트에 있는 useEffect를 실행시키지 않는 것이 효율적이기 때문에 useCallback을 이용해 boxstyle 함수를 묶어주는 것이 좋습니다.

 

묶어준 코드는 다음과 같습니다.

import {useState, useCallback} from 'react';


export default function App () {

	const [val, setVal] = useState(100)
    const [isDark, setIsDark] = useState(false)
    
    const boxstyle = useCallback(() => {
    	return (
        	backgroundColor: 'pink',
            width: `${val}px`,
            height: `${val}px`,
        )
    },[val])



	return (
    	<div style={{
        	background : isDark ? "black" : "white"
        }}>
    		<input 
            	type = 'number'
                value = {val}
                onChange = { (e) => {setVal(e.target.value)} }
            />
            <button onClick={setIsDark(!isDark)}>
            	{isDark ? 다크 모드 : 화이트 모드}
            </button>
            <Box createBoxStyle={boxstyle} />
    	</div>
    )
}

이렇게 작성하면 박스 크기를 지정하는 val state가 변경될때만 boxstyle 함수가 초기화 되기 때문에 불필요하게 Box 컴포넌트의 useEffect를 호출하는 상황을 해결할 수 있습니다.

사실 위의 useCallbak 내부의 콜백 함수는 값만 반환해 주기 때문에 useMemo를 사용해도 문제 없습니다.

 



7. useReducer

리액트에서 state 관리를 위한 또 다른 Hook입니다. 보통 여러개의 하위값을 가지고 있는 복잡한 state를 다루는 경우 useReducer를 사용하면 코드를 매우 깔끔하게 짤 수 있습니다.

 

이러한 useReducer는 Reducer, Dispatch, Action으로 이루어져 있습니다.

간단하게 설명하자면 state를 변경해 주는 작업을 하는 것이 Reducer이고, 값의 변경을 요구하는 행위가 Dispatch, 요구하는 내용이 Action입니다.

Dispatch( Action )  ====>  Reducer( State, Action ) =====> state에 업데이트

 

예제를 보면서 이해해 봅시다.

 

(예제 1 : 은행)

import {useState, useReducer} from 'react';

//reducer : state의 값을 변경해줌
//dispatch : state를 업데이트 하기 위한 요구
//actioin : 요구의 내용, 보통 객체의 형태로 보냅니다. 

const reducer = (state, action) = > {
	switch (action.type) {
    	case 'deposit':
        	return state + action.payload;
        case 'withdraw':
        	return state + action.payload;
        default:
        	return state;
    }
};

const ACTION_TYPE = {
	deposit : 'deposit',
    withdraw : 'withdraw',
}

export default fucntion App () {
	
    const [val, setVal] = useState(0)
    const [money, dispatch] = useReducer(reducer, 0) //(리듀서, 초기값)
    
    
    
	return (
    <div>
    	<h2>은행</h2>
        <p>잔고 : {money}원</p>
        <input 
        	type = 'number'
            value = {val}
            onChange = {(e) => { setVal(parseInt(e.target.value)) ]}
        	step = 1000
        />
        <button onClick={() => {
        	dispatch({
            	type: ACTION_TYPE.deposit,
                payload : number,
            })
        }}>출금</button>
        <button onClick={
        	dispatch({
            	type: ACTION_TYPE.withdraw,
                payload : number,
            })
        }>예금</button>
    </div>
    )
}

위의 코드 처럼 state를 업데이트 해줄 reducer를 설정해 주고, dispatch로 원하는 작업을 설정할 수 있습니다. 사실 위와 같은 상황에서 money state는 useState를 사용해도 될 정도로 간단한 작업을 수행합니다. 다음 예제를 통해 복잡한 작업에서 빛을 발하는 useReducer를 확인해 봅시다.

 

(예제 2 : 출석부)

import {useState, useReducer} from 'react';

const ACTYPE = {
	add : 'add',
    delete : 'delete',
    hereCheck : 'hereCheck',
    
}

const initial = {
	count: 0,
    students : [],
}

const reducer = (state, action) => {
	switch(action.type) {
    	case ACTYPE.add:
            return {
            	count: state.count + 1,
                students: [...state.students, action,addInfo],
            };
        
        case ACTYPE.delete:
            return {
            	count:state.count - 1
                students: state.students.filter((infoOne) => infoOne.id !== action.deleteId),
            };
            
        case ACTYPE.hereCheck:
        	return {
            	count : state.count,
                students : state.students.map((student)=>{
                	if(action.hereId === student.id) {
                    	{
                        	...student, 
                        	isHere: !student.isHere	
                        }
                    };
                    return student; 
                }) 
            }
        
        default:
        	return state;
    }
};



export default function App () {
	
    const [name, setName] = useState("");
    const [nameBook, dispatch] = useReducer(reducer, initial);
    
    const handleAdd = () => {
    	dispatch({
        	type: ACTYPE.add,
        	addInfo: {
            	id: Date.now(),
                name : name,
                isHere : false
            },
        })
        setName("")
    }
    
    const handleDelete = (id) => {
    	dispatch({
        	type : ACTYPE.delete,
        	deleteId : id,
        })
    }
    
    const handleHere = (id) => {
    	dispatch({
        	type: ACTYPE.hereCheck,
            hereId : id,
        })
    }
    
    
	return (
    <div>
    	<h2> 출석부 출석부~ </h2>
        <p> 총 학생 수 : {nameBook.count} </p>
        <input
        	type = 'text'
            value = {name}
            onChange = { (e) => { setName(e.target.value) } }
        />
        <button onClick={handleAdd}> 추가 </button>
        {
        	nameBook && 
         	<ul>
        		{
            		nameBook.students.map((student) => {
            			return ( 
                        	<li key={student.id}>
                                <span 
                                	onClick = {() => handleHere(student.id)}
                                    style = {
                                        student.isHere 
                                        ? {color:gray, textDecoration: line-through}
                                        : {}
                                    }
                                }>
                                    {student.name}
                                </span>
                            	<button onClick={() => handleDelete(student.id)}>삭제</button>
                        	</li>
                        )
            		})
            	}
        	</ul>   
        }
    </div>
    )
}

위 코드를 보면 state에 대한 복잡한 작업을 reducer에서 수행해 주는 것을 볼 수 있습니다. 앞으로 위와 같이 하위 값들이 있는 state에 대한 복잡한 작업을 해야하는 경우 useReducer를 사용하여 가독성 높은 코드를 작성해 보면 좋을 것 같습니다.

 

4. useContext

리액트로 만든 어플리케이션은 여러개의 컴포넌트들로 이루어져 있습니다. 최상위 App 컴포넌트에서 아래로 뻗어나가는 트리 형태로 이루어져 있고, 부모 컴포넌트에서 자식 컴포넌트로 Props가 전달이 되는 구조입니다.

 

만약 컴포넌트가 매우 많이 존재하는 상태에서 특정 Props를 전달해야하는 상황이라면 전달하는 과정이 매우 복잡해 질 것이고 Props를 수정해야 한다면 복잡도는 더욱 올라갈 것입니다.

 

리액트에서는 이러한 문제점을 간편하게 해결해 줄 수 있는 Context API를 제공해 주고 있습니다. 이러한 Context API 전역적으로 사용해야 하는 데이터를 여러 컴포넌트에서 편리하게 가져와 사용할 수 있는 방법을 제공합니다.

 

context를 이용해 전역적으로 공유할 데이터를 지정하고 각 컴포넌트에서는 useContext로 해당 context를 가져와 사용할 수 있습니다. 이러한 context는 꼭 필요한 경우에만 사용해야 합니다.

 

context를 이용해 데이터를 전역적으로 사용할 수 있도록 하는 방법을 알아봅시다.

 

1. 먼저 context를 만들어줍니다.

import {createContext} from 'react';

interface MyContextType = {
	name: string;
    age: number;
    isAdult: boolean;
    setAge: Dispatch<SetStateAction<number | undefined>>;
}

const MyCOntext = createContext<MyContextType>(
	// 데이터 예시 
    // js라면 그냥 null을 넣고 
    // 원하는 데이터를 Provider를 통해 뿌려줄 때 value에 작성하면 됩니다.
    {
    	name : null,
        age : null,
        isAdult : null,
        setAge: () => {},
    }
);

 

2. 만들어준 context의 데이터를 최상위 컴포넌트에서 Provider로 뿌려줍니다.

import {MyContext} from './context/MyContext';
import Component1 from './component/Component1';

export default function App () {
	return (
    <MyContext.Provider value={{name = '이름', age = 23, isAdult = true, setAge}}>
    	<Component1/>
    </MyContext.Provider>
    )
}

 

3. context 데이터를 사용해야 하는 컴포넌트에서 useContext로 받아서 사용하면 됩니다.

import {useContext} from 'react'
import {MyContext} from './context/MyContext';

export default function ChildComponent () {
	
    const {name, age, isAdult, setAge} = useContext(MyContext);
    
	return (
    <div>
    	<p> name : {name} <p>
        {isAdult && 
        	<p> age : {age} </p>
        }
    </div>
    )
}

 



 

 

5. useMemo

참고한 강의 :

 

useMemo에서 Memo는 Memoization을 뜻합니다. 이는 동일한 값을 리턴하는 함수를 반복적으로 호출해야 한다면, 맨 처음 계산한 값을 메모리에 저장하고, 필요할 때마다 호출하는 것이 아니라 저장한 값을 꺼내서 재사용하는 것을 말합니다. 

 

useMemo의 구조

const value = useMemo(()=>{
	//메모이제이션 할 값을 계산해서 반환해 주는 함수
    return 함수();
},[해당 요소의 값이 업데이트 될 때만 콜백 함수를 다시 호출하여 메모이제이션])

만약 의존성 배열이 빈 배열이라면 처음 렌더링 될 때의 값을 메모이제이션 하고 이후에는 메모이제이션 된 값을 가져와 사용합니다. 이런 useMemo도 무분별하고 사용한다면 오히려 성능을 저하시킬 수 있습니다. 메모이제이션 자체가 값을 재활용하기 위해 따로 메모리를 소비하는 것이기 때문에 불필요한 값들까지 캐시해 버린다면 성능이 저하될 것입니다. 그렇기에 꼭 필요할 때만 사용해야 합니다.

 

예제를 통해 이해해보면 좋을 것 같습니다.

import {useState} from 'react'

const hardCal = (number) => {
	console.log("어려운 계산");
    for (let i = 0; i < 999999999; i++) {}
    return number + 10000;
}

export default function App(){
	
    const [hnumber, setHnumber] = useState(1);
    
    const hSum = hardCal(hnumber);

	return (
    	<div>
    		<h3> 어려운 계산 </h3>
            <input 
            	type = 'number'
                value = {hnumber}
                onChange = {(e) => {setHnumber(parseInt(e.target.value))}}
            />
            <span> +10000 = {hSum} </span>
        </div>
    )
}

위와 같은 코드의 경우 state에 10000을 더하는 시간이 엄청 오래 걸리기 떄문에 렌더링 할때마다 계산하는 함수를 호출하는 것은 매우 비효율적입니다. 이 경우 저 hardCal 함수로 변수를 초기화 하는 코드에 useMemo를 이용하면 됩니다. 이럴 경우 특정 조건에만 함수를 다시 호출하기 때문에 불필요하게 시간이 오래걸리는 어려운 계산을 하지 않습니다.

useMemo를 적용한다면 다음 코드와 같습니다. useMemo를 적용할 경우 효과적인 상황을 연출하기 위해 쉬운 계산 함수를 사용하는 코드도 함께 작성했습니다.(강의 참고)

import {useState, useMemo} from 'react'

const hardCal = (number) => {
	console.log("어려운 계산");
    for (let i = 0; i < 999999999; i++) {}
    return number + 10000;
}

const eazyCal = (number) => {
	console.log("쉬운 계산");
    return number + 10000;
}

export default function App(){
	
    const [hnumber, setHnumber] = useState(1);
    const [enumber, setEnumber] = useState(1);
    
    //const hSum = hardCal(hnumber);
    const hSum = useMemo(()=>{
    	return hardCal(hnumber);
    },[hnumber]);
    const eSum = eazyCal(enumber);

	return (
    	<div>
    		<article>
                <h3> 어려운 계산 </h3>
                <input 
                    type = 'number'
                    value = {hnumber}
                    onChange = {(e) => {setHnumber(parseInt(e.target.value))}}
                />
                <span> +10000 = {hSum} </span>
            <article>
            
            <article>
                <h3> 쉬운 계산 </h3>
                <input 
                    type = 'number'
                    value = {enumber}
                    onChange = {(e) => {setEnumber(parseInt(e.target.value))}}
                />
                <span> +10000 = {eSum} </span>
            <article>
        </div>
    )
}

위처럼 hCal에 대한 반환 값을 useMemo를 이용해 캐싱해 준다면, hnumber가 업데이트 되지 않는한 재 랜더링이 일어날 때, 캐시한 반환 값을 보내주기 때문에 기존 렌더링 할때마다 어려운 계산을 하던 문제를 해결할 수 있습니다. 즉 쉬운 계산의 숫자를 바꾸면 쉬운 계산 함수만 실행되는 것이고, 어려운 계산의 숫자를 바꿔줄 때만 어려운 계산 함수를 호출하며 효율적인 코드를 작성할 수 있습니다.

 

하지만 위와 같은 상황이 실제 개발 환경에서 일어날 경우는 많지 않습니다. 실제로 useMemo를 유용하게 사용하는 상황을 알아봅시다.

import {useState, useEffect} from 'react';


export default function App () {
	const [num, setNum] = useState(0);
    const [isKorea, setIsKorea] = useState(true);
    
    const location = isKorea ? "한국" : "외국"
    
    useEffect(()=>{
    	console.log("useEffect 호출");
    },[location])

	return (
    	<div>
    		<section>
            	<h2>하루에 몇끼 먹음?</h2>
                <input 
                	type = 'number'
                    value = {num}
                    onChange = {(e) => { setNum(e.target.value)} }
                />
            </section>
            
            <section>
            	<h2>어느 나라에 있음?</h2>
                <p> 나라 : {location} </p>
                <button onClick={ () => {setIsKorea(!isKorea)} }> 비행기 탐 </button>
            </section>
        </div>
    )

}

위의 코드의 경우에는 location이 변경 되었을 때만 useEffect가 잘 호출됩니다. 하지만 여기서 location을 객체로 만든다면 어떻게 될까요?? location 값 변경에 관여하는 isKorea가 아닌 num이 변경되도 useEffect가 실행됩니다. 그 이유는 객체 타입은 내부 내용을 메모리 공간에 넣고 해당 메모리에 대한 주소를 변수에 할당되기 때문입니다.

 

여기서 원시 타입과 객체 타입의 차이를 설명드리자면 

원시 타입

  • String
  • Number
  • Boolean
  • Null
  • Undefined
  • Bight
  • Symbol

을 제외한 모든 타입이 객체 타입인데 대표적으로 Object와 Array가 있습니다. 그리고 원시 타입이 변수에 값을 초기화 할 경우 바로  값을 넣을 수 있는 공간을 만들어 저장하는 데, 객체 타입은 그 내부 내용이 너무 크기 때문에 위에서 설명했듯이 메모리 공간을 할당 받아 메모리에 값을 넣어주고 해당 메모리의 주소를 변수에 할당합니다. 이로 인해서 location에 객체를 넣어준다면, 값 변경에 직접적으로 관여하는 isKorea가 아닌 다른 state가 변경되면서 재 렌더링이 되더라도 location은 새로운 메모리 주소를 할당받기 때문에 useEffect는 location이 변경된 것으로 인지합니다.

 

이런 경우에 location을 useMemo에 넣어줌으로써 문제를 해결할 수 있습니다.

즉, location에 할당되는 주소 값을 isKorea가 변경되었을 때만 새로 메모리 주소 값을 할당 받고 변경된 값을 객체에 반영할 수 있게 하는 것입니다. 코드는 다음과 같습니다.

import {useState, useEffect, useMemo} from 'react';


export default function App () {
	const [num, setNum] = useState(0);
    const [isKorea, setIsKorea] = useState(true);
    
    const location = useMemo(()=>{
    	return (
        	{
    			country: isKorea ? "한국" : "외국"
    		}
        )
    },[isKorea]) 
    
    useEffect(()=>{
    	console.log("useEffect 호출");
    },[location])

	return (
    	<div>
    		<section>
            	<h2>하루에 몇끼 먹음?</h2>
                <input 
                	type = 'number'
                    value = {num}
                    onChange = {(e) => { setNum(e.target.value)} }
                />
            </section>
            
            <section>
            	<h2>어느 나라에 있음?</h2>
                <p> 나라 : {location} </p>
                <button onClick={ () => {setIsKorea(!isKorea)} }> 비행기 탐 </button>
            </section>
        </div>
    )

}

 

다음 글에서는 useCallback과 useReducer를 정리해 보겠습니다.

+ Recent posts