업무를 보던 중, depth 제한이 없는 재귀 리스트를 구현해야 할 일이 생겼습니다.
이 글에서는...
- 프론트엔드 요구사항을 재귀 컴포넌트와 BFS를 사용하여 해결합니다.
- React.js를 기반으로 설명합니다.
사용자의 약관 동의 여부를 관리해야 했었습니다.
그런데 약관은 여러 개가 있을 수 있어 리스트로 관리하는 동시에,
각 리스트이 아이템 하위에 또 다른 약관 리스트가 있을 수 있었습니다.
데이터 구조 | UI 구조 |
---|---|
해당 요구사항을 전달 받은 후, 최대한 효율적으로 컴포넌트를 관리하기 위해
개발 이전에 목표를 잡고 시작했습니다.
렌더링을 한 번만 허용하기에, 데이터는 단 하나의 객체로 관리되어야합니다.
const initialAgreementState = {
id: 1,
title: "약관 동의서",
isAgree: false,
list: [
{
id: 1,
title: "제 1장 약관 동의",
isAgree: false,
list: [
{
id: 1,
title: "사용자 개인정보 ...",
isAgree: false,
list: [
{ id: 1, title: "민감 정보 동의", isAgree: false },
{ id: 2, title: "식별 정보 동의", isAgree: false },
{ id: 3, title: "일반 정보 동의", isAgree: false },
],
},
{ id: 2, title: "서비스 이용정보 ...", isAgree: false },
{ id: 3, title: "정보 관리 주체 ...", isAgree: false },
],
},
{
id: 2,
title: "제 2장 약관 동의",
isAgree: false,
list: [
{ id: 1, title: "...", isAgree: false },
{ id: 2, title: "...", isAgree: false },
],
},
],
};
그렇다면 위와 같은 객체가 구성됩니다.
해당 객체를 어떻게 컴포넌트로 보여줄 수 있을까요?
먼저 해당 객체를 useState에 주입한 후 시작해보겠습니다.
const [agreement, setAgreement] = useState(initialAgreementState);
그 다음, RecursiveContainer 컴포넌트를 만들어 렌더링시켜줍니다.
<Agreement.Container>
<Agreement.Heading>{agreement.title}</Agreement.Heading>
<AgreementRecursiveContainer
agreement={agreement}
/>
</Agreement.Container>
내부 구현체는 다음과 같이 구현합니다.
const AgreementRecursiveContainer = ({
agreement,
}) => {
return (
<>
<Agreement.List>
{agreement.list.map((agreementDetail) => (
<Agreement.Item
key={agreementDetail.id}
>
<Agreement.Heading>{agreementDetail.title}</Agreement.Heading>
{agreementDetail.list && (
<AgreementRecursiveContainer
agreement={agreementDetail}
/>
)}
</Agreement.Item>
))}
</Agreement.List>
</>
);
};
해당 컴포넌트를 사용하면 최상위 depth부터 시작하여 map을 통해 리스트를 순회하고,
각 아이템마다 약관 이름을 렌더링한 후, 그 아이템의 하위 약관이 있는지를 체크합니다.
의사 코드를 통해 컴포넌트가 아닌 재귀 함수 느낌으로 표현해보자면 다음과 같습니다.
const recursive = (agreement) => {
for (let i = 0; i <= agreement.length; i++) {
렌더링(agreement[i]);
if(isHave(agrement[i].list)) recursive(agreement[i]);
}
}
의사 코드라기엔 JS에 가까운데요, 정상적으로 작동하는 코드는 아니기에 의사 코드라고 정정해두겠습니다.
탈출 조건은 약관 아래 하위 약관이 존재하는가?
가 되고, 재귀 함수 내 로직은
func(약관)
-> 약관 아래 하위 약관 리스트 순회(i)
-> func(i)
다음과 같이 작동합니다.
이렇게 되면 더 이상 하위 리스트가 존재하지 않는 최하위 아이템까지 렌더링이 되며,
agreementDetail.list &&
이라는 조건부 렌더링에 의해 재귀 컴포넌트도 자연스럽게 탈출이 가능해집니다.
잘 표현이 되네요!
단 하나의 객체로 동의 여부를 관리하려면, 문제는 어떤 약관을 클릭했는지를 찾아야한다는 것입니다.
객체 구조를 다시 한번 살펴볼까요?
const initialAgreementState = {
id: 1,
title: "약관 동의서",
isAgree: false,
list: [
{
id: 1,
title: "제 1장 약관 동의",
isAgree: false,
list: [
{
id: 1,
title: "사용자 개인정보 ...",
isAgree: false,
list: [
{ id: 1, title: "민감 정보 동의", isAgree: false },
{ id: 2, title: "식별 정보 동의", isAgree: false },
{ id: 3, title: "일반 정보 동의", isAgree: false },
],
},
{ id: 2, title: "서비스 이용정보 ...", isAgree: false },
{ id: 3, title: "정보 관리 주체 ...", isAgree: false },
],
},
{
id: 2,
title: "제 2장 약관 동의",
isAgree: false,
list: [
{ id: 1, title: "...", isAgree: false },
{ id: 2, title: "...", isAgree: false },
],
},
],
};
다음 사진처럼 제가 '민감 정보 동의'를 클릭했을 때, 어떻게 하나의 객체에서 해당 클릭을 감지할 수 있을까요?
저는 컴포넌트마다 identifierList
props를 주입하여 문제를 해결했습니다.
<AgreementRecursiveContainer
agreement={agreement}
identifierList={[]}
/>
최상위 RecursiveContainer에 identifierList
라는 빈 배열을 전달합니다.
<AgreementRecursiveContainer
agreement={agreementDetail}
identifierList={[...identifierList, agreementDetail.id]}
/>
그 다음, RecursiveContainer에서 재귀적으로 자기 자신을 렌더링하는 부분에 다음과 같이 ID를 추가하는 거죠!
이런 식으로 컴포넌트를 렌더링할 경우 각 컴포넌트가 가지는 identifierList는 다음과 같습니다.
이렇게 되면 어디에서 클릭하든, identifierList를 index 끝까지 순회하여 탐색할 수 있게 되는거죠.
[1, 1, 2]에 있는 약관 동의서를 클릭했다면,
최상위 약관 리스트의 1번째
-> 1번째 약관 하위 리스트의 1번째
-> 1번째 약관 하위 리스트의 2번째
와 같이 찾을 수 있습니다!
클릭했을 경우 identifierList를 사용해 트리 탐색을 진행하는 함수를 작성해보겠습니다.
const pickAgree = (
agreement: AgreementInterface,
identifierList: Array<number>,
idx: number
) => {
return {
...agreement,
list: agreement.list.map((item: AgreementInterface) => {
if (item.id !== identifierList[idx]) return item;
if (identifierList[idx + 1])
return pickAgree(item, identifierList, idx + 1);
return { ...item, isAgree: !item.isAgree };
}),
};
};
로직은 다음과 같습니다.
1. item.id
가 identifierList[idx]
가 아니라면 우리가 pick한 약관이 아닙니다. 그렇기에 원본 데이터를 그대로 반환합니다.
2. identifierList[idx + 1]
이 존재하는 경우, 현재 depth 보다 더 하위의 depth에 존재하는 약관을 클릭했음을 의미합니다. 재귀 함수를 사용하여 하위 depth로 탐색을 시작합니다.
3. 두 조건이 모두 아닐 경우, 사용자가 클릭한 약관이기에 isAgree
를 토글합니다.
이렇게 identifierList를 사용하여 어떤 약관을 pick했는지를 탐색할 수 있겠네요!
데이터를 변경한 후 setState
를 통해 한 번만 업데이트시켜주면 끝입니다!
const handleToggleAgreementClick = (identifierList) => {
setAgreement(pickAgree(agreement, identifierList, 0));
}
해당 함수를 재귀 컴포넌트의 onClick에 전달시켜, 각 컴포넌트별로 다른 identifierList
를 인자로 전달하면 완성입니다!
보통 상위 약관의 동의 여부는 하위 약관 전체를 동의하느냐에 따라 제어됩니다.
약관을 클릭했을 때 하위 약관도 연쇄적으로 동의 여부를 제어하는 로직을,
BFS와 유사한 방법을 통해 구현해보겠습니다.
const withChild = (item, agreeStatus) => {
if (item.list)
return {
...item,
isAgree: agreeStatus,
list: item.list.map((child) => withChild(child, agreeStatus)),
};
return {...item, isAgree: agreeStatus}
};
훨씬 쉽네요! 하위로 전파할 동의/미동의를 agreeStatus로 두고, 재귀 함수와 map을 사용하여
존재하는 최하위의 약관까지 agreeStatus를 반영시키면 됩니다.
위 컴포넌트에서 pick한 객체의 상태를 반환하는 로직에, 해당 함수를 덮어씌워주면 끝입니다!
const pickAgree = (
agreement: AgreementInterface,
identifierList: Array<number>,
idx: number
) => {
return {
...agreement,
list: agreement.list.map((item: AgreementInterface) => {
if (item.id !== identifierList[idx]) return item;
if (identifierList[idx + 1])
return pickAgree(item, identifierList, idx + 1);
// return { ...item, isAgree: !item.isAgree };
return withChild(item, !item.isAgree);
}),
};
};
withChild는 하위 탐색을 진행하며 자신의 agreeStatus도 변경하기 때문에, 굳이 따로 주입해주지 않고 인자로 전달해주면 연쇄적으로 작동이 되겠네요!
위 섹션에서 말한 문장을 다시 가져와 보겠습니다.
보통 상위 약관의 동의 여부는 하위 약관 전체를 동의하느냐에 따라 제어됩니다.
그렇다면 상위 약관이 미동의임에도 하위 약관 전체가 동의면 상위 약관도 동의가 될 것이고,
반대로 상위 약관이 동의임에도 하위 약관이 단 하나라도 미동의면 상위 약관도 미동의가 되어야 겠네요!
구현해 둔 pickAgree
함수를 조금만 변경하면 구현할 수 있습니다!
const pickAgree = (
agreement,
identifierList,
idx
) => {
const toggledList = agreement.list.map((item) => {
if (item.id !== identifierList[idx]) return item;
if (identifierList[idx + 1])
return pickAgree(item, identifierList, idx + 1);
return { ...item, isAgree: !item.isAgree };
});
const isEveryAgree = toggledList.every((item) => item.isAgree);
const isDisagree = toggledList.find((item) => !item.isAgree);
return {
...agreement,
isAgree: <-
list: toggledList,
};
};
바로 list에 주입하여 return하던 재귀 리스트 값을 toggledList
로 저장한 후,
해당 리스트의 isAgree
값을 찾아 현재 아이템의 isAgree
값을 변경해주면 됩니다!
const currentIsAgree =
isEveryAgree ? true
: isDisagree ? false
: !item.isAgree
동의는 약관 리스트가 전부 동의여야 변경되지만,
미동의는 단 하나라도 미동의면 바로 변경된다는 점을 유의해야 합니다!
알고리즘에서만 사용하던 재귀 함수와 BFS, 트리 구조를 실무에서 사용할 일이 생겨
굉장히 흥미로운 경험이었습니다.
이외에도 댓글 하위 답글 하위 답글 ... 좋아요 버튼
과 같은 요구사항에서도 비슷한 방식으로 재귀 컴포넌트를 적용할 수 있을 것 같네요!
상태 관리가 간편한 단 한 번의 렌더링으로 확장성 있게 요구사항을 해결하고 싶으시다면 해당 방법을 추천드립니다!
판단로직을 !(list?.any(a => a.isAgree == false)) || isAgree
이런식으로 바꿀수 있을것같네요.
state같은 곳에서 Not을 사용하기 위해 !를 사용하는데요. 이게 find나 every같이 조건문과 같이있으니 읽기 힘드네요. == true를 사용하시는 것이 좋아보여요
Container와 RecursiveContainer를 분리하지 않아도 될 것 같습니다. 그냥 컨테이너에서 list 존재 여부로 하위 렌더링을 판단하면 될 것 같아요.
withChild
는 함수 이름으로 적합하지 않아 보입니다. 함수는 꼭 동사로 시작하시는게 좋고, 무슨 일을 하는지 이름만으로 판단이 될 수 있어야 합니다.
identifierList
의 네이밍으로 어떤 일을 하는지 알기 어렵다라는 문제가 있는데요, 네이밍 문제는 차치하고 이것이 과연 필요한가라는 생각이 듭니다. 저라면 클릭 리스너를 가장 상위의 컨테이너에 위임하고, 리스트 아이템에 탐색경로를 표기해서 (예를 들어 2-2-1) 클릭 시 이 탐색경로를 통해 활성화 된 약관 항목을 판단 및 업데이트 할 것 같습니다. 그리고 각 아이템(또는 컨테이너)에서 업데이트 된 상태를 확인해서 자신이 이 탐색경로에 포함되어 있는지 확인하면 되지 않을까 싶어요. 그러면 상태는 단 하나만 필요하고 지금처럼 탐색하는 로직과 identifierList를 드릴링하지 않아도 될 것 같습니다. 현재는 하는 일에 비해 너무 많은 정보가 불필요하게 여기저기 흩뿌려지게 되어서 바람직하지 않은 설계인 것 같습니다.
안녕하세요 우빈님. 글 잘읽었습니다! 설명해주신 컴포넌트 구현으로 중첩이 들어가고 하나를 특정하는 로직이 있는 모든곳에 잘 사용할 수 있겠다는 생각이 들었어요. 아래에 글을 읽던중 궁금한 점이 있어 남겨둡니다. 알려주시면 감사하겠습니다!
identifierList를 ID로 만드셨는데 이게 ID는 유니크한 값으로 봐도 무방할까요?
disAgree만 조회한후 length로 판단하지 않고 everyAgree를 따로 조회하신 이유가 있을까요?
as-is는 {모두 동의한다 true / 동의하지 않는게 있다면 false} 인데
to-be는 {동의하지 않는게 존재한다 false / 없다 true }가 나중 성능면에서도 더 좋을 것 같아요.
부모를 동의했을때 아래로 isAgree가 전파될까요?