Hits

🌁 Factory 도입 배경

엔지니오에서는 새롭게 대기업과 NCS서비스를 개발하게 되었습니다.

대기업과 NCS서비스의 경우에는, 기존에 서비스중인 자격증, 공기업과 다르게 지문이 존재하기도 하며, 데이터의 key값(JSON key)이 기존과 달랐습니다.

아래와 같이, 기존에 사용중인 문제풀이 화면에 존재하는 UI 컴포넌트를 최대한 재사용하는 방향으로 개발을 진행해야 했기 때문에, 서버에서 제공하는 데이터를 UI 컴포넌트에서 사용되는 형태로 가공해, 기존 UI컴포넌트에서 사용하는 데이터 형태와 동일한 형태로 변경하기로 했습니다. 여기서 Factory는 데이터를 가공하는 Layer를 의미합니다.


🧐 컴포넌트와 요구사항 및 데이터 형태 분석

🔎 컴포넌트 분석

위에서 보이는 문제풀이 화면은 문제해설선지헤더푸터 등 여러 가지 컴포넌트로 구성되어 있습니다.

그리고, 내부적으로는 다음과 같은 프로세스로 데이터를 관리하고 화면에 렌더링합니다.

  1. Recoil에서 제공하는 Selector를 이용해, 서버에서 전체문제에 대한 데이터를 배열 형태로 Fetching
  2. 서버에서 Selector를 통해 가져온 데이터를 Atom에 Set하여, 전역적으로 관리
  3. 사용자가 현재 보고 있는 문제의 데이터를 CurrentProblem이라 이름 붙여진 Atom에 Set하고, 문제를 이동할 때 마다, CurrentProblem값을 최신화
  4. CurrentProblem에 담긴 데이터를 화면에 렌더링

가장 간단한, 문제(Problem) 컴포넌트의 경우, 기존에는 다음과 같이 작성되어 있습니다.

const katexClassName = "katex-string-problem";
 
const Problem = ({ currentProblem }: ProblemProps) => {
  useRenderKatex(currentProblem.problem, katexClassName);
 
  return (
    <div className={styles.contentMain}>
      <span className={`${katexClassName}`}>
        {currentProblem.isKatex ? (
          <KatexLoader />
        ) : (
          RawStringParser(currentProblem.problem)
        )}
      </span>
    </div>
  );
};
 
export default Problem;
const katexClassName = "katex-string-problem";
 
const Problem = ({ currentProblem }: ProblemProps) => {
  useRenderKatex(currentProblem.problem, katexClassName);
 
  return (
    <div className={styles.contentMain}>
      <span className={`${katexClassName}`}>
        {currentProblem.isKatex ? (
          <KatexLoader />
        ) : (
          RawStringParser(currentProblem.problem)
        )}
      </span>
    </div>
  );
};
 
export default Problem;

현재 문제(CurrentProblem)을 Props로 주입 받아(Props로 주입 받은 이유는 여러 곳에서 재사용하기 위함),

해당 문제의 katex여부(isKatex)를 확인 후, 해당 문제가 katex가 적용된 문제일 경우, katex 라이브러리를 이용해, 내부에 지정된 HTML Element에 변환된 내용을 렌더링하고,

katex가 아니라면, RawStringParser라는 변환 함수를 통해 화면에 렌더링합니다. (katex는 latex형식으로 작성된 Text를 수식과 같은 형태로 화면에 렌더링하는 라이브러리입니다)

🔎 요구사항 및 데이터 형태 분석

대기업을 제외하고 NCS 서비스의 경우, 요구사항 같은 경우에는 다음과 같았습니다.

  1. 기존의 문제 풀이 화면 재사용
  2. 기존에 존재하는 문제 입력 형식에서, 문제 입력 칸에 문제와 지문을 함께 입력
  3. 문제와 지문의 경우, //NCS// 를 기준으로 나뉜다 (//NCS//를 기준으로 위는 지문, 아래는 문제)

🧐 구현 가능 형태 분석

이제 분석은 끝났으니, 어떤 식으로 구현을 해야 할지 분석해보았습니다.

새롭게 컴포넌트를 만들지 않고, 구현할 수 있는 형태는 다음과 같았습니다.

  1. 데이터를 사용하는 컴포넌트(Problem Component)에서 데이터를 가공해, 화면에 렌더링
  2. 컴포넌트에서 Flag를 주입받아, 분기 처리를 통한 렌더링
  3. 현재 풀고 있는 문제(CurrentProblem)가 변경될 때, 데이터를 가공
  4. 서버에서 데이터를 가져왔을 때, 데이터를 가공

1번의 경우, 다음과 같은 형태로 구현될 수 있습니다. (실제로 구현을 했었던 코드)

const katexClassName = "katex-string-problem";
 
const Problem = ({ currentProblem }: ProblemProps) => {
  const isNcsProblem = useMemo(
    () => isNCSProblem(currentProblem.problem),
    [currentProblem.problem]
  );
  useRenderKatex(
    isNcsProblem
      ? currentProblem.problem
      : splitProblem(currentProblem.problem),
    katexClassName
  );
 
  return (
    <div className={styles.contentMain}>
      <span className={`${katexClassName}`}>
        {currentProblem.isKatex ? (
          <KatexLoader />
        ) : (
          RawStringParser(
            isNcsProblem
              ? currentProblem.problem
              : splitProblem(currentProblem.problem)
          )
        )}
      </span>
    </div>
  );
};
 
export default Problem;
const katexClassName = "katex-string-problem";
 
const Problem = ({ currentProblem }: ProblemProps) => {
  const isNcsProblem = useMemo(
    () => isNCSProblem(currentProblem.problem),
    [currentProblem.problem]
  );
  useRenderKatex(
    isNcsProblem
      ? currentProblem.problem
      : splitProblem(currentProblem.problem),
    katexClassName
  );
 
  return (
    <div className={styles.contentMain}>
      <span className={`${katexClassName}`}>
        {currentProblem.isKatex ? (
          <KatexLoader />
        ) : (
          RawStringParser(
            isNcsProblem
              ? currentProblem.problem
              : splitProblem(currentProblem.problem)
          )
        )}
      </span>
    </div>
  );
};
 
export default Problem;

현재 문제(CurrentProblem)의 NCS 문제여부(isNcsProblem)을 확인 후, 이에 따라 데이터를 가공(splitProblem 함수를 이용)해 렌더링 하는 방식입니다.

2번의 경우, 다음과 같은 형태로 구현될 수 있습니다. (1과 차이점은 컴포넌트 내부에서 Flag를 정하는지 아니면, Flag를 주입받는지)

const katexClassName = "katex-string-problem";
 
const Problem = ({ currentProblem, isNcsProblem }: ProblemProps) => {
  useRenderKatex(
    isNcsProblem
      ? currentProblem.problem
      : splitProblem(currentProblem.problem),
    katexClassName
  );
 
  return (
    <div className={styles.contentMain}>
      <span className={`${katexClassName}`}>
        {currentProblem.isKatex ? (
          <KatexLoader />
        ) : (
          RawStringParser(
            isNcsProblem
              ? currentProblem.problem
              : splitProblem(currentProblem.problem)
          )
        )}
      </span>
    </div>
  );
};
 
export default Problem;
const katexClassName = "katex-string-problem";
 
const Problem = ({ currentProblem, isNcsProblem }: ProblemProps) => {
  useRenderKatex(
    isNcsProblem
      ? currentProblem.problem
      : splitProblem(currentProblem.problem),
    katexClassName
  );
 
  return (
    <div className={styles.contentMain}>
      <span className={`${katexClassName}`}>
        {currentProblem.isKatex ? (
          <KatexLoader />
        ) : (
          RawStringParser(
            isNcsProblem
              ? currentProblem.problem
              : splitProblem(currentProblem.problem)
          )
        )}
      </span>
    </div>
  );
};
 
export default Problem;

1번과 2번 방법 모두, 결국 Problem 컴포넌트 내부를 수정해야 합니다.

따라서, 후에 NCS뿐만 아니라, 다른 형태의 문제에 대한 요구사항이 생길 경우, 컴포넌트 내부의 로직을 또 다시 수정해야 합니다.

따라서, 3번 또는 4번의 방법을 사용하기로 결정했습니다.

3번의 경우, 현재 풀고 있는 문제가 변경될 때 마다, 데이터를 가공해야 합니다.

즉, 서버에서 가져온 데이터를 캐싱하더라도, 매번 데이터를 가공하는 로직을 실행해야 합니다.

결과적으로, 4번의 방법을 사용하기로 결정했습니다.


🏭 Factory 도입

앞서 말씀드린, 다음과 같은 4단계의 프로세스 중, 서버에서 가져온 데이터를 가공하는 형태는 2번 단계에서, 데이터를 가공하는 형태입니다.

  1. Recoil에서 제공하는 Selector를 이용해, 서버에서 전체문제에 대한 데이터를 배열 형태로 Fetching
  2. 서버에서 Selector를 통해 가져온 데이터를 Atom에 Set하여, 전역적으로 관리
  3. 사용자가 현재 보고 있는 문제의 데이터를 CurrentProblem이라 이름 붙여진 Atom에 Set하고, 문제를 이동할 때 마다, CurrentProblem값을 최신화
  4. CurrentProblem에 담긴 데이터를 화면에 렌더링

따라서, selector에서 데이터를 Atom에 Set할 때, Factory Layer를 거쳐 Set하도록 구현하는 방식으로 코드를 작성 했습니다.

const getProblems = selector<OriginProblemType[]>({
  key: "getProblems",
  get: async ({ get }) => {
    const problemType = get(problemType);
    const { examIdx, detailIdx } = searchParams();
    try {
      const res = await request("요청 주소");
      return res.data;
    } catch (err) {
      //errorHandling
    }
  },
  set: ({ set }, originProblem) => {
    set(ProblemsAtom, ProblemFactory(originProblem));
  },
});
 
function ProblemFactory(originProblem) {
  return originProblem.map((data) => {
    return {
      ...data,
      problem: isNcs(data.problem) ? splitProblem(data.problem) : data.problem,
    };
  });
}
 
function useProblem() {
  const problems = useRecoilValue(ProblemAtom);
  const [originProblem, setProblemsAtom] = useRecoilState(getProblems);
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    setProblemsAtom(originProblem);
    setIsLoading(false);
  }, []);
 
  return { problems, isLoading };
}
const getProblems = selector<OriginProblemType[]>({
  key: "getProblems",
  get: async ({ get }) => {
    const problemType = get(problemType);
    const { examIdx, detailIdx } = searchParams();
    try {
      const res = await request("요청 주소");
      return res.data;
    } catch (err) {
      //errorHandling
    }
  },
  set: ({ set }, originProblem) => {
    set(ProblemsAtom, ProblemFactory(originProblem));
  },
});
 
function ProblemFactory(originProblem) {
  return originProblem.map((data) => {
    return {
      ...data,
      problem: isNcs(data.problem) ? splitProblem(data.problem) : data.problem,
    };
  });
}
 
function useProblem() {
  const problems = useRecoilValue(ProblemAtom);
  const [originProblem, setProblemsAtom] = useRecoilState(getProblems);
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    setProblemsAtom(originProblem);
    setIsLoading(false);
  }, []);
 
  return { problems, isLoading };
}

이제, 서버에서 보내주는 데이터의 형식이 달라질 경우, ProblemFactory함수만 수정하면 된다.

결과적으로, UI 컴포넌트의 내부 로직을 구현하거나, 변경하지 않을 수 있었다.

Recoil을 사용하기 때문에, 비동기 Selector의 getset등이 포함되어 조금 더 복잡한 느낌이 들기는 한다.

React Query를 사용할 경우, setQueryData를 통해, 쿼리 데이터를 수정할 수 있으며, RTK Query는 transformReponse를 통해, 쿼리 데이터를 수정할 수 있다.

이런 기능이 있는 라이브러리를 사용할 경우, 해당 기능에 Factory를 추가해서 사용한다면 훨씬 더 쉽게 Factory를 적용할 수 있다.