고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

돈이 새고 있었어 API 호출 최적화로 성능 개선하기

고윤태 2024. 3. 14. 11:05
안녕하세요 이번에 작성하게 될 내용은 제가 회사에서 API 호출을 최적화 한 경험에 대해 공유하고자 글을 작성하게 되었습니다.

💻 개발 환경

React-native(CLI) + TypeScript로 진행되었습니다.

🧐 구현하게 된 계기

간단한 서비스 및 환경 소개

서비스

저희 회사는 O2O(Online to Offline) 서비스를 운영 중입니다. 간단히 말해, 가맹점은 서비스에 등록하여 유저들에게 자신의 매장을 홍보하고, 유저들은 이 서비스를 통해 다양한 가맹점에서 제공하는 서비스를 이용하며 경험을 공유할 수 있습니다. 회사는 이 플랫폼을 통해 가맹점과 유저 사이의 연결고리 역할을 합니다.

  • 가맹점: 가맹점은 서비스 제공 회사에 구독비를 지불하고, 자신의 가맹점을 등록하여 서비스 플랫폼에 참여합니다. 이를 통해 유저로부터 서비스 제공의 기회를 얻고, 유저가 작성한 블로그 글을 통해 추가 홍보 효과를 기대할 수 있습니다.
  • 유저: 유저는 등록된 가맹점에서 서비스를 이용합니다. 서비스 이용 후에는 자신의 경험을 바탕으로 블로그 글을 작성하여 가맹점을 홍보함으로써, 가맹점에 대한 인지도를 높이는 데 기여합니다.
  • 회사: 회사는 가맹점의 등록을 관리하고 플랫폼을 통해 가맹점과 유저 간의 연결을 촉진합니다. 가맹점으로부터는 구독비를 통해 수익을 얻고, 유저에게는 다양한 가맹점의 정보와 이용 후기를 제공하여 선택의 폭을 넓혀줍니다.

이런 식의 비즈니스로 구성이 되어 있습니다.


환경

회사의 App은 React Native로 구현이 되어있고 서버는 NodeJs로 구현하여 Aws E2C에 배포되어 있습니다.

 

EC2(Elastic Compute Cloud)란?

EC2는 AWS에서 제공하는 클라우드 컴퓨팅 서비스.

이 서비스를 통해서 아마존이 각 세계에 구축한 데이터 센터의 서버용 컴퓨터들의 자원을 원격으로 사용할 수 있다. 쉽게 말해, 아마존으로부터 한 대의 컴퓨터를 임대하는 것이다. AWS가 제공하는 URL(Public DNS)를 통해 이 컴퓨터에 접근할 수 있다.

API 호출 최적화 여정

상황을 전달받다..

아침에 백엔드 개발자와 스몰톡을 나누던 중, 최근 사용자 수 증가에도 불구하고 E2C 서버 비용이 예상보다 많이 나오는 문제에 대해 언급했습니다. E2C의 Auto Scaling 기능이 사용량 증가에 따라 서버 인스턴스를 추가하고 감소 시 제거하는 과정을 자동화하고 있음에도 불구하고, 비용이 과도하게 발생하고 있었습니다. 이에 따라 우리는 API 호출량에 주목했습니다. 사용자 수에 비해 비정상적으로 높은 API 호출량이 발견되었기 때문입니다.

원인을 찾다.

자리로 돌아오는 길에 머릿속을 스치는 코드가 있었습니다.

확인해 보니, StoreCard(가명) 컴포넌트가 사용자가 특정 매장을 이용했는지 확인하기 위해 과도한 API 호출을 유발하고 있었습니다.

예를 들어, 앱에 500개의 매장이 있고 100명의 사용자가 접속할 경우, 단순 계산으로도

 

500(매장 수) X 유저 수(100) X 500(매장에 대한 이용정보 조회) = 25,000,000회

 

API 호출이 발생하는 것으로 나타났습니다. 이는 매장 목록을 사용자가 자주 조회하는 특성상, 큰 비용과 부하를 유발하는 주요 원인이었습니다.

이라는 엄청난 양의 API 호출량이 계산이 됩니다. 심지어 매장 목록은 유저들이 자주 조회하기 때문에 빈번하게 발생합니다.

이러한 상황을 백엔드 분께 공유드리고 리팩토링을 진행했습니다.

🤔 기능 산출 및 개발 내용

방법 공유

이 문제를 해결하기 위해, StoreCard에서 개별적으로 이용 내역을 조회하는 대신, 매장 리스트를 조회할 때 한 번에 모든 이용 내역을 불러와 전역 상태에 저장하는 방식으로 리팩터링 하기로 결정했습니다. 이를 위해 Recoil에 이용내역 정보를 관리하기로 결정했습니다.

기존 구현의 문제점

StoreCard.tsx

import { addDate, diffDate } from 'utils/date';
import React, { useEffect, useState } from 'react';
import { View, Text } from 'react-native';

//기존 API 대체 원래의 리턴 값은 API 요청 시 이용내역이 존재하면 이용날짜 없다면 error
const getLastUsedDate = (id: string): Promise<Date> => {
  return new Promise(r => {
    r(new Date());
  });
};

interface Company {
  id: string;
  revisitPeriod: number;
  //...etc
}

interface Props {
  company: Company;
}

const StoreCard = ({ company }: Props) => {
  const [lastUsedDate, setLastUsedDate] = useState(new Date(1990, 1, 1));

  useEffect(() => {
    const fetchLastDate = async () => {
      try {
        const lDate = await getLastUsedDate(company.id);
        setLastUsedDate(lDate);
      } catch (e) {
        setLastUsedDate(new Date(1990, 1, 1));
      }
    };

    fetchLastDate();
  }, [company.id]);

  const remain = lastUsedDate ? diffDate(addDate(lastUsedDate, company.revisitPeriod), new Date()) : 0;

  return (
    <View>
      {/* Image */}
      <View>
        <Text>{`재이용 D-${remain}`}</Text>
      </View>
      {/* Content */}
      <View>
        <Text>Title</Text>
        <Text>Sub</Text>
      </View>
    </View>
  );
};

export default StoreCard;

 

기존 StoreCard를 보시면 Card 마다 API를 요청하여 해당 Store에 대한 최근 이용 내역을 불러와서 재이용 기간을 표시해주고 있습니다.

 

변경 내역

이 방법을 대신하여 StoreCard 별로 최근 이용내역을 불러오는 것이 아닌 유저의 이용내역을 불러와서 

해당 StoreCard(가명) 컴포넌트의 lastUsedDate라는 State를 Recoil을 통해 전역 상태에 넣어서 보관하면 될 것 같습니다.

 

types/index.ts, atom.ts

// types/index.ts
export interface CompanyLastUsedDates {
  [key: string]: Date;
}


// atom.ts
/*

매장 이용내역
Object
[key : companyId] : value(최근 이용일 이용내역의 createdAt)

*/

export const companyLastUsedDates = atom<CompanyLastUsedDates>({
  key: 'companyLastUsedDates',
  default: {},
});

 

이제 기초 준비는 끝난 상태에서 제가 할 일은 백엔드 개발자 분에게 유저에 대한 60일간의 이용내역을 받을 수 있는 API를 추가를 요청해야 합니다.

 

필요한 이유와 변경 방향성에 대해 설명을 드리면서 요청을 하니 백엔드 개발자 분도 흔쾌히 수락해 주시고 만들어주시기로 하셨습니다.

새로 추가된 API를 리스트를 요청할 때 같이 요청하도록 코드를 변경합니다.

 

기존에 구현이 되어있던 코드를 어떤 식으로 변경했는지 보여드리겠습니다.

 

StoreCard.tsx

import { diffDate, addDate } from '@mayacrew/utils/date';
import React from 'react';
import { View, Text } from 'react-native';
import { useRecoilValue } from 'recoil';
import { companyLastUsedDates } from '../../recoil/atom';

interface Company {
  id: string;
  revisitPeriod: number;
  //...etc
}

interface Props {
  company: Company;
}

const StoreCard = ({ company }: Props) => {
  const lastUsedDates = useRecoilValue(companyLastUsedDates);
  const lastUsedDate = lastUsedDates[String(company.id)] || new Date(1990, 1, 1);
  const remain = diffDate(addDate(lastUsedDate, company.revisitPeriod), new Date());

  return (
    <View>
      {/* Image */}
      <View>
        <Text>{`재이용 D-${remain}`}</Text>
      </View>
      {/* Content */}
      <View>
        <Text>Title</Text>
        <Text>Sub</Text>
      </View>
    </View>
  );
};

export default StoreCard;

 

LastUsedDate를 recoil로 관리하는 전역 state의 값을 사용하도록 변경했습니다.

 

List.tsx

import axios from 'axios';
import React, { useEffect } from 'react';
import { FlatList } from 'react-native';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { companyLastUsedDates as companyLastUsedDatesState, companyList } from '../../recoil/atom';
import StoreCard from './StoreCard';

const List = () => {
  const [list, setList] = useRecoilState(companyList);
  const setCompanyLastUsedDates = useSetRecoilState(companyLastUsedDatesState);

  useEffect(() => {
    const fetch = async () => {
      const [result1, result2] = await Promise.all([axios.get('API URL'), axios.get('API URL')]);
      setList(result1.data);
      setCompanyLastUsedDates(result2.data);
    };

    fetch();
  }, []);

  return (
    <FlatList
      data={[...list]}
      renderItem={item => {
        return <StoreCard company={item.item} />;
      }}
    />
  );
};

export default List;

 

리스트 부분은 목록을 볼러올 때 매장들의 이용내역도 불러오도록 변경했습니다.

 

List 쪽 작업을 끝내고 난 뒤 보니 List 컴포넌트를 제외하고도 제가 놓친 즐겨찾기 목록이라던지 하는 여러 곳에서 생각보다 이용내역 State를 업데이트를 해줘야 하는 케이스가 존재했습니다.

 

해당 부분을 custom hook으로 빼서 사용하면 좋을 것 같다 판단하여 작업을 진행했습니다.

 

useUpdateCompanyLastUsedDates.tsx

import { useNavigation } from '@react-navigation/core';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { companyLastUsedDates, userState } from '../recoil/atom';
import { NavigatorParamList } from '../screens/NavigatorParamList';
import { getCompanyLastUsedDates } from '../utils';

const useUpdateCompanyLastUsedDates = () => {
  const setCompanyLastUsedDates = useSetRecoilState(companyLastUsedDates);
  const navigation = useNavigation<NativeStackNavigationProp<NavigatorParamList>>();
  const user = useRecoilValue(userState);

  useEffect(() => {
    let isMount = true;

    const fetchLastUsedDates = async () => {
      try {
        const dates = await getCompanyLastUsedDates(user.uid);
        if (isMount) {
          setCompanyLastUsedDates(dates);
        }
      } catch (error) {
        console.error("Failed to fetch company's last used dates:", error);
      }
    };

    const unsubscribe = navigation.addListener('focus', fetchLastUsedDates);

    return () => {
      unsubscribe();
      isMount = false;
    };
  }, [navigation, user.uid, setCompanyLastUsedDates]);
};

export default useUpdateCompanyLastUsedDates;

 

코드 설명

  • 마지막 매장 이용 날짜 데이터 업데이트: 사용자가 앱의 특정 화면에 접근할 때마다, 해당 사용자의 마지막 매장 이용 날짜 데이터를 서버로부터 가져와 업데이트합니다.
  • Recoil 상태 관리: 전역 상태 관리 라이브러리인 Recoil을 사용하여, 가져온 마지막 매장 이용 날짜 데이터를 앱 전역 상태에 저장합니다.
  • Navigation 이벤트 리스닝: React Navigation 라이브러리를 통해 화면 포커스(focus) 이벤트를 감지하고, 해당 이벤트 발생 시 데이터를 업데이트하는 로직을 실행합니다.

해당 Hook을 사용하여 LIst 컴포넌트, Favorite 컴포넌트 등 간편하게 Store에 대한 최근 이용 내역을 업데이트할 수 있게 변경했습니다.

 

List.tsx

import axios from 'axios';
import React, { useEffect } from 'react';
import { FlatList } from 'react-native';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { companyLastUsedDates as companyLastUsedDatesState, companyList } from '../../recoil/atom';
import StoreCard from './StoreCard';
import useUpdateCompanyLastUsedDates from '../../hooks/useUpdateCompanyLastUsedDates';

const List = () => {
  const [list, setList] = useRecoilState(companyList);

  useUpdateCompanyLastUsedDates();

  useEffect(() => {
    const fetch = async () => {
      const [result1] = await axios.get('API URL');
      setList(result1.data);
    };

    fetch();
  }, []);

  return (
    <FlatList
      data={[...list]}
      renderItem={item => {
        return <StoreCard company={item.item} />;
      }}
    />
  );
};

export default List;

 

😁 글 작성 후기

요즘 현생이 너무 많이 바빠서 블로그 글을 작성할 시간이 너무 없었는데요.. 오랜만에 글을 작성하려고 하니 예전에 비해 확실히 문장을 구성하는 능력이 떨어진 것 같은 기분이 많이 들었습니다. 그래서 글 퀄리티가 떨어지진 않을까 내가 작성하고자 하는 방향성대로 잘 가고 있는지 확인을 유독 많이 하며 작성한 글인 것 같습니다.

 

평소 저는 UI, UX 같은 사용성 개선도 좋아하지만 개발자로서의 성능 개선도 매우 좋아하는 편입니다.

이번에 저의 작은 코드 수정이 E2C 서버의 사용량에 생각보다 큰 영향을 준 결과를 보니 너무나 뿌듯한 작업이었습니다.