개발자
류준열

비즈니스 로직을 프론트에서 구현해야 하는 경우

프론트에서 복잡한 비즈니스로직을 구현해야 하는 경우가 있다. 예를들어서 조건에 따라 보여줘야 하는 문구가 상이하고 그 조건들이 자주 변경되는 경우에는 일정한 값들을 서버에서 내려주고 조건문은 프론트에서 정리하는 것이 더 좋을 것 같다.

사이드이펙트에서 자유로운 테스트 코드

테스트할 컴포넌트 안에 뷰와 api 연동코드(사이드이펙트)가 함께 있다면, 외부변화에 따라 테스트코드가 실패할수도 성공할수도 있다.

우리는 사이드이펙트에서 자유롭지 못한 테스트가 아닌 사이드이펙트에서 자유로운 테스트를 만들어야 한다.

일단 예시로 ASIS, TOBE를 보자.

ASIS: api 연동(사이드이펙트)와 뷰가 함께 있는 경우

const getPlaces = () => fetch(`${API_URL}/places`)

const PlacePage = () => {
    const {data, isLoading} = useQuery({...queryFn: getPlaces })
    
    return (
    <>{isLoading ? 
        <div>loading..</div>:
        <ul>{data.map(e=><li>..</li>)}</ul>
    }</>
    )
}

이 컴포넌트를 테스트하려면 다음과 같이 작성하게 될 것이다.

describe('Place 페이지',()=>{
    it('api로 장소리스트를 받아서 화면에 렌더링한다.',()=>{
        render(<PlacePage/>)
        await new Promise(r => setTimeout(r, 1000));
        const places = screen.getByAllTestId('place')
        expect(places).length(10)
    })
})

이러한 방식은 api로부터 오는 응답값의 불변을 보장할 수 없기 때문에 외부환경에 따라 성공할수도 실패할수도 있다.
예를들면 백엔드 개발자의 리팩토링으로 응답데이터가 res.data -> res.data.places로 바뀌는 경우 data.map은 유효하지 않다.
이는 api 응답값을 모킹한다해도 해결할 수 없는 문제이다.

이러한 현상이 발생하는 근본적인 원인은 세부구현이 의존대상(data)과 결합되어 있기 떄문이다.

TOBE: api 연동(사이드이펙트)와 뷰가 분리된 경우

컴포넌트를 이렇게 바꿔보자.

const getPlaces = () => fetch(`${API_URL}/places`)

const PlacePage = () => {
    const {data, isLoading} = useQuery({...queryFn: getPlaces })
    
    return (
    <Places places={data}/>
    )
}

이렇게 되면 외부의 영향을 받지 않는 <Places /> 테스트를 할 수 있다.

describe('Place 페이지',()=>{
    it('props로 받은 데이터를 렌더링한다.',()=>{
        render(<Places places={['강남','낙성대',....,'여의도']}/>)
        const places = screen.getByAllTestId('place')
        expect(places).length(10)
    })
})

<Places /> 컴포넌트의 세부 구현은 외부환경에 영향을 받지 않게 된다.
의존대상을 외부에서 ['강남','낙성대',....,'여의도']로 주입받았기 때문이다.

위 테스트가 유효하지 않은 경우는 해당 api의 응답데이터 자체가 변경되었을 때 뿐이다.

유지보수하기 편한 코드

사이드이펙트가 없는 코드는 유지보수하기 편할 것이다. 걱정할 것이 없기 때문이다.
하지만 이는 불가능하기 때문에 사이드이펙트를 최대한 한 곳에 몰아넣는 것이 좋다.

위 TOBE 에서는 서버에서 받아온 data(사이드이펙트)를 <Places places={data}/>의 외부에서 주입하여 세부구현과 의존대상을 분리시켰다.

// api 연동(사이드이펙트)와 뷰가 분리된 경우
const getPlaces = () => fetch(`${API_URL}/places`)

const PlacePage = () => {
    const {data, isLoading} = useQuery({...queryFn: getPlaces })
    
    return (
    <Places places={data}/>
    )
}

관심사 분리를 해야 하는 이유

위에서 설명한 테스트코드와 별개로도, 뷰와 api 연동부는 관심사가 애초에 다르다.

만약 복잡한 비즈니스 로직이 있을 경우, 비즈니스 로직은 대체로 잘 변하지 않지만 뷰는 자주 변할 수 있다. 때로는 그 역도 마찬가지다.

그렇다면 뷰와 비즈니스로직은 분리되어야 마땅하다.

뷰와 비즈니스 로직이 한 컴포넌트안에 있다면 이는 의존대상과 세부 구현이 결합되어 있는 상황이다.

위의 ASIS,TOBE 비교에서 보았듯 의존대상과 세부구현이 결합되어 있다면, 사이드이펙트의 위험에서 자유롭지 못하다.

여기서 분리하는 방식은 의존대상을 외부에서 주입하여 세부구현과 의존대상을 분리하는 것이다.

UI가 자주 바뀌는 카드 컴포넌트의 뷰와 비즈니스 로직 분리.

부트캠프였던 전 회사에서 부트캠프 지원자들이 사용하는 카드 UI가 있었는데 당시 여러 사업들을 시도하면서 프론트에서 여러 조건문들을 변경해야하는 상황이 있었다.

api 응답 data는 변하지 않았기 때문에 api 연동부와 비즈니스로직이 분리되어야 했다.

아래 Application.tsx는 3부분으로 나눌 수 있다.

  1. data를 받아오는 query
  2. api에서 받아온 data를 가공하는 ApplicationCardViewModel.ts
  3. 고정된 Layout을 가진 ApplicationCard.tsx

코드를 보자.

const Application = ({ application }) => {
    const [viewModel,setViewModel] = useState(null);
    // query
    const {data,isLoading,isError} = useQuery(...,() => getApplicationCard,...);
    
    // ApplicationCardViewModel에 data넣어서 가공
    useEffect(()=>{
        const viewModel = new ApplicationCardViewModel(data.application);
        setViewModel(viewModel);
    },[data])
    
    // 뷰만 렌더링하는 ApplicationCard 컴포넌트 props에 viewModel 주입
    return (
    <>
    ...
      <ApplicationCard viewModel={viewModel} />
    </>
    )

비즈니스로직의 결과물인 viewModel<ApplicationCard viewModel={viewModel}/>의 외부에서 주입하여 <ApplicationCard />의 세부 구현과 의존대상인 viewModel을 분리시킨 상황이다.

data를 가공하는 ApplicationCardViewModel.ts는 다음과 같다.
역시 서버에서 받아온 data를 외부에서 받아서 ApplicationCardViewModel의 세부구현과 분리된 상황이다.

작업시에는 ApplicationCardViewModel.ts 내부의 코드들만 변경하면 된다.

export default class ApplicationCardViewModel {
    constructor(application: Application) {
        this._application = application;
    }
    // 1-1. 상단 분류 문구
    public get topPhrase(): string {
      // 지원서가 취소/보류/하차 상태인 경우에는 그 경우에 따라 변경됩니다.
      if (this._application.applyingStatus === ApplyingStatus.APPLYING) {
        return this.isCDSProgram ? '진행 중' : '지원 중';
      }
    ...
    // 5-3. 수강 기간 여부
    private get isCourseOngoing(): boolean {
      return new Date() >= this._product.serviceStartDate && new Date() <= this._product.serviceEndDate;
    }

아래 테스트코드는 외부 환경과 무관하게 유효한 코드이다. (정책변경등으로 응답 data 자체가 변경되는 경우 제외)

그렇기 때문에 ApplicationViewModel.ts의 테스트 통과 여부를 사이드 이펙트 없이 확인할 수 있다.

describe('1. 지원 절차 및 지원 시작/마감 미설정 경우', () => {
      describe('1.1.1. 대기의 경우', () => {
        // Given
        const application = {
          id: 1,
          uuid: 'a45d18c4-7dc2-4232-9f99-07504316de80',
          status: 'PENDING',
          paymentType: 'GOVFUNDING',
          product: mockProduct,
          userApplicationSteps: [],
          submission: []
        };

        // When
        const viewModel = new ApplicationCardViewModel(application);

        it('카드 상단 문구는 "지원중" 이여야 합니다.', () => {
          // Then
          expect(viewModel.topPhrase).toEqual('지원중');
        });

        it('날짜는 안내되지 않아야 합니다.', () => {
          // Then
          expect(viewModel.dateGuide).toEqual(' ');
        });

        it('지원 상태는 안내되지 않아야합니다.', () => {
          // Then
          expect(viewModel.isDisplayProgressStatus).toEqual(false);
        });

        it('카드는 "활성" 상태로 "지원 하기"로 표기되어야 합니다.', () => {
          // Then
          expect(viewModel.cardAndButtonState).toMatchObject({
            cardStatus: 'active',
            buttonText: '지원하기',
            buttonTextColor: ColorPaletteType.WHITE,
            buttonStatus: StatusType.ACTIVE
          });
        });
      });

결론

비즈니스로직이 복잡하다면 세부구현과 의존대상을 분리시킴으로 사이드이펙트를 최대한 배제해보자.

참고

https://wiki.lucashan.space/essay/mystery-of-testing-for-react/
개발바닥 유튜브
전 회사 동료분께 배운 내용들