배열로 데이터 컬렉션을 관리하라!

김지유·2025년 8월 6일

JavaScript

목록 보기
1/2
post-thumbnail

오늘은 자바스크립트 코딩의 기술 이라는 책의 제2장 배열로 데이터 컬렉션을 관리하라 부분을 소개 해보겠습니다.

먼저 제목의 등장하는 컬렉션을 알아보겠습니다.

컬렉션이란?

'프로그래밍 언어가 제공하는 값을 담을 수 있는 컨테이너'

자바스크립트에서 컬렉션은 인덱스 기반과 키 기반으로 나눌 수 있습니다.

인덱스 기반의 컬렉션에는 Arrays, TypedArray.
키 기반의 컬렉션에는 Objects, Map, Set, WeakMap, WeakSet 으로 나뉩니다.
ES5까지는 Array, Object 만 있다가 ES6 부터 Map,Set 등이 추가 되었습니다.


그럼 이제 본격적인 내용으로 들어가 보겠습니다.

배열로 유연한 컬렉션을 생성하라

컬렉션을 선택할 때는 해당 정보로 어떤 작업을 수행할 것인지 고려해야 합니다.
어떤 형태로든 조작 해야 한다면 배열이 가장 적합한 컬렉션입니다.
또한, 배열을 사용하지 않는 경우에도 반드시 배열에 적용되어 개념을 빌리게 됩니다.

배열의 유연성

배열은 놀라운 수준의 유연성을 갖추고 있습니다.
배열은 순서를 갖기 때문에 이를 기준으로 값을 추가하거나 제거할 수 있고 모든 위치에 값이 있는지 확인할 수도 있고 배열을 정렬해서 순서를 새로 지정할 수도 있습니다.

객체와 배열의 연결

우리는 배열 외 다른 컬렉션도 사용하기는 해야 합니다.
그렇지만 먼저 배열을 깊이 이해하면 코드를 상당히 개선할 수 있습니다.

예를 들어 객체를 순회하려면 먼저 Object.keys()를 실행해서 객체의 키를 배열에 담은 후 생성한 배열을 이용해 순회합니다.
배열을 객체와 반복문의 가교를 활용하는 것입니다.

배열과 이터러블

배열은 여기저기 어디에나 등장하는데, 배열에 이터러블(iterable)이 내장되어 있기 때문입니다.
이터러블은 간단히 말해 '컬렉션의 현재위치를 알고 있는 상태에서 컬렉션의 항목을 한 번에 하나씩 처리하는 방법'입니다.
문자열처럼 자체적으로 이터러블이 존재하거나 Object.keys() 처럼 이터러블로 변환할 수 있는 데이터 형식이라면 배열에 수행하는 모든 동작을 동일하게 실행할 수 있습니다.

배열을 다른 구조와 확장

또한 컬렉션 개념의 거의 대부분을 배열 형태로 표현할 수 있습니다.
즉, 배열을 특별한 컬렉션으로 쉽게 변환하거나 다시 배열로 만들 수 있습니다.

이와 같은 키-값 저장소의 경우

const dog={
    name: 'Don',
    color: 'black'
};
dog.name;

키-값 저장소와 동일한 개념을 2차원 배열로 설명할 수 있습니다.
내부의 배열은 두 가지 항목만 갖습니다.
첫 번째 항목은 키이고, 두 번째 항목은 값입니다.
이러한 구조를 키-값 쌍이라고 부르며,
특정한 키의 값을 찾을 때는 먼저 일치하는 키 이름을 찾고 두 번째 항목을 반환하면 됩니다.

const dogPair=[
    ['name','Don'],
    ['color', 'black'],
];
function getName(dog){
    return dog.find(attribute=>{
        return attribute[0]==='name';
    })[1];
}

Includes()로 존재 여부를 확인하라

배열을 다룰 때는 존재 여부 확인이 필요합니다.
ES5때 까지는 존재 여부 확인이 다소 번거로웠습니다.

예를 들어 배열이 특정 문자열을 포함하고 있는지 확인하려면 문자열의 위치를 찾고 문자열이 존재하면 문자열의 색인으로 위치를 확인 할 수 있습니다.
반대로 문자열이 존재하지 않으면 -1이 반환됩니다.

이 문제는 색인이 0이 될 수 있는데 자바스크립트에서 0은 false(거짓)으로 평가됩니다.

따라서 실제로 존재하는 값이라도 확인 결과가 false로 평가될 수 있습니다.
그렇기 때문에 코드에는 숫자와 비교하는 과정을 거쳐야 합니다.

const sections=['contact', 'shipping'];

function displayShipping(sections){
    return sections.indexOf('shipping') > -1;
}

하지만 ES6에 추가된 새로운 기능으로 이런 비교 절차를 생략할 수 있게 되었습니다.

includes() 라는 새로운 배열 메서드를 이용하면 값이 배열에 존재하는지 여부를 확인해서 불값으로 true 또는 false를 반환합니다.

const sections=['contact', 'shipping'];

function displayShipping(sections){
    return sections.includes('shipping');
}

이렇게 includes()를 사용하면 -1로 비교하는 것을 누락해서 색인이 0인 경우를 false로 처리해버리는 실수를 피할 수 있습니다.

펼침 연산자로 배열을 본떠라

배열은 데이터를 다룰 때 높은 수준의 유연성을 제공합니다.
그렇지만 배열에 수많은 메서드가 있으므로 혼란스럽거나 조작과 부수 효과로 인한 문제에 맞닥뜨릴 수 있습니다.

이러한 문제는 펼침 연산자를 사용하면 최소한의 코드로 배열을 빠르게 생성하고 조작할 수 있습니다.

펼침 연산자는 마침표 세 개(...)로 표시하며 펼침 연산자의 기능으로는 배열에 포함된 항목을 목록으로 바꿔줍니다.
목록은 매개변수 또는 새로운 배열을 생성할 때 사용할 수 있는 일련의 항목입니다.
펼침 연산자는 이런 작은 기능으로 여러 가지 이점을 가져다줍니다.

먼저 배열에서 항목을 제거하려고 할때 반복문만 사용한다면 다음과 같이 작성할 수 있습니다.

function removeItem(Items,removable){
    const updated=[];
    for (let i=0;i<Items.length;i++){
        if(items[i]!==removable){
            updated.push(items[i]);
        }
    }
    return updated;
}

이 코드는 꽤 많습니다.
가능 하면 코드를 단순하게 유지하는 것이 좋습니다.

코드를 단순하게 하려고 배열 메서드인 splice() 를 사용할 수도 있습니다.
이 메서드로 배열에서 항목을 제거할 수 있습니다.
위의 함수를 리팩토링을 거쳐 다음과 같이 단순한 함수로 수정합니다.

function removeItem(items, removable){
    const index=items.indexOf(removable);
    items.splice(index, 1);
    return items;
}

이 코드의 문제는 splice() 메서드가 원본 배열을 조작한다는 점입니다.

const books=['practical vim', 'moby dick', 'the dark tower'];
const recent=removeItem(books,'moby dick');
const novels=removeItem(books,'practical vim');

배열 novels에는 'the dark tower'만 포함되어 있습니다.
처음 removeItem()을 호출할 때 배열 books를 전달하고 'moby dick'을 제거한 배열을 반환받습니다.
그렇지만 이 과정에서 배열 books도 변경었습니다.
다음 함수에 배열 books를 전달했을 때는 배열에 두 가지 항목만 남아있습니다.

이처럼 원본 배열이 직접 변경되는 상황은 예기치 못한 오류를 유발할 수 있어, 조작은 되도록 피하는 것이 좋습니다.
위에 경우에는 books를 const로 할당하기도 했습니다.
따라서 조작되지 않을 것이라고 생각할 수도 있지만 항상 그렇지는 않습니다.

splice() 메서드가 for 문의 괜찮은 대안처럼 보이겠지만, 조작은 많은 혼란을 가져오므로 가능하면 피하는 것이 좋습니다.

slice()

끝으로 배열에는 slice()라는 방법이 하나 더 있습니다.
slice() 메서드는 원본 배열을 변경하지 않고 배열의 일부를 반환합니다.
slice() 메서드에 인수로 시작점과 종료점을 전달하면 그 사이에 있는 모든 항목을 반환합니다.
또는 종료점을 생략하고 시작점만 인수로 전달하면 시작점부터 배열의 마지막 항목까지 반환합니다.
그 후에 concat()을 이용해서 배열 조각을 연결할 수 있습니다.

function removeItem(items, removable){
    const index=items.indexOf(removable);
    return items.slice(0, index).concat(items.slice(index+1));
}

이 코드는 원본 배열을 변경하지도 않고 새로운 배열을 생성했으며 코드도 많지 않습니다.
그렇지만 무엇이 반환되는지 정확하지 않습니다.
무슨일이 일어나는지를 눈으로만 봐서는 정확히 어떤 작업을 하는 코드인지 알기 어렵습니다.

바로 이런 곳이 펼침 연산자를 사용하기에 적합합니다.
실제 배열처럼 보이기도 하고, 원래 배열에 영향을 주지 않고 새로운 배열을 생성해줍니다.

function removeItem(items, removable){
    const index=items.indexOf(removable);
    return [...items.slice(0,index),...items.slice(index+1)];
}

push() 매서드 대신 펼침 연산자로 원본 변경을 피하라

배열을 조작하기 위해 흔히 사용하는 push() 메서드는 새로운 항목을 배열 뒤에 추가해 원본 배열을 변경합니다.
즉, 항목을 추가하면 원본 배열을 조작하는 것이다.
이 문제는 펼침 연산자를 이용하면 원본 배열이 조작되는 부수 효과를 방지할 수 있습니다.

장바구니 상품 목록을 받아서 내용을 요약하는 간단한 함수입니다.
이 함수는 할인 금액을 확인하고 할인 상품이 두 개 이상이면 오류 객체를 반환합니다. 만약 오류가 없다면 상품을 많이 구매한 사람에게 사은품을 줍니다.

const cart = [{
    name: 'The Foundation Triology',
    price: 19.99,
    discount: false,
},
{
    name: 'Godel, Escher, Bach',
    price: 15.99,
    discount: false,
},
{
    name: 'Red Mars',
    price: 5.99,
    discount: true,
},
];
const reward = {
    name: 'Guide to Science Fiction',
    discount: true,
    price: 0,
};
function addFreeGift(cart){
    if(cart.length>2){
        cart.push(reward);
        return cart;
    }
    return cart;
}
function summarizeCart(cart){
    const discountable=cart.filter(item=>item.discount);
    if(discountable.length>1){
        return {
            error: '할인 상품은 하나만 주문할 수 있습니다.',
        };
    }
    const cartWithReward=addFreeGift(cart);
    return {
        discounts: discountable.length,
        items: cartWithReward.length,
        cart: cartWithReward,
    };
}

이 코드는 조작이 위험해 보이지 않는 예시 입니다.
하지만 코드 정리를 위해 모든 변수를 함수의 상단으로 옮기면 어떻게 될까요?

function summarizeCartUpeated(cart){
    const cartWithReward=addFreeGift(cart);
    const discountable=cart.filter(item=>item.discount);
    if(discountable.length>1){
        return {
            error: '할인 상품은 하나만 주문할 수 있습니다.',
        };
    }
    return {
        discounts: discountable.length,
        items: cartWithReward.length,
        cart: cartWithReward,
    };
}

함수 addFreeGift()를 사용하면 배열 cart를 조작합니다.
상품이 세 개 이상이면, 할인이 적용된 아이템이 추가됩니다.
반환값, 즉 장바구니에 사은품을 추가해 새로운 변수에 할당하더라도, 원본 배열인 cart가 이미 조작된 후 입니다.
결국 상품을 세 가지 이상 선택하고 그 중 하나가 할인 상품인 모든 고객에게 오류가 발생합니다.

그러면 이 문제를 해결할 방법은 바로 펼침 연산자 입니다.

function addGift(cart){
    if (cart.length>2){
        return [...cart,reward];
    }
    return cart;
}
function summarizeCartSpread(cart){
    const cartWithReward=addGift(cart);
    const discountable=cart.filter(item=>item.discount);
    if(discountable.length>1){
        return {
            error: '할인 상품은 하나만 주문할 수 있습니다.',
        };
    }
    return {
        discounts: discountable.length,
        items: cartWithReward.length,
        cart: cartWithReward,
    };
}

기존의 배열을 가져다 대괄호에 펼쳐 넣고, 새로운 상품을 배열의 마지막에 추가하면 됩니다.
결론적으로 내용을 목록으로 다시 쓰기만 하면 됩니다.
이렇게 하면 새로운 배열을 생성하기 때문에 원본 배열을 변경할 가능성은 전혀 없습니다.
원본의 내용만 재사용해 새로운 배열을 만드는 것입니다.

펼침 연산자로 정렬에 의한 혼란을 피하라

지금까지 펼침 연산자를 이용해서 여러 가지 조작 함수를 대체하는 방법을 살펴보았습니다.
그렇다면 대체하기 쉽지 않은 함수가 있을 때는 어떻게 해야 할까요?
답은 펼침 연산자로 원본 배열의 사본을 생성하고 사본을 조작하면 됩니다.

직원 정보가 담긴 배열을 이름 또는 근속 연수를 기준으로 정렬하는 애플리케이션을 개발하려고 합니다.

먼저 직원 정보가 담긴 배열입니다.

const staff=[
    {
        name: 'Joe',
        years:10,
    },
    {
        name: 'Theo',
        years:5,
    },
    {
        name: 'Dyan',
        years:10,
    },
];

다음으로 이름 또는 근속 연수로 정렬하는 몇가지 함수를 추가합니다.

function sortByYears(a,b){
    if (a.years===b.years){
        return 0;
    }
    return a.years-b.years;
}
const sortByName=(a,b)=>{
    if (a.name===b.name){
        return 0;
    }
    return a.name>b.name?1:-1;
};

이제 사용자가 열의 제목을 클릭하면 배열에서 정렬 함수를 호출합니다.
예를 들어 사용자가 근속 연수에 따라 정렬시키면 함수가 배열을 정렬하고 갱신합니다.

staff.sort(sortByYears);
// [
//     {
//         name: 'Theo',
//         years:5,
//     },
//     {
//         name: 'Joe',
//         years:10,
//     },
//     {
//         name: 'Dyan',
//         years:10,
//     },
// ]

배열을 정렬할 때 해당 배열은 변경됩니다.
함수 실행이 끝난 것처럼 보일지라도, 실제로는 배열의 변경 사항이 그대로 유지됩니다.

이번에는 사용자가 이름순으로 정렬한 경우입니다.
역시나 배열은 조작됩니다.

staff.sort(sortByYears);
// [
//     {
//         name: 'Dyan',
//         years:10,
//     },
//     {
//         name: 'Joe',
//         years:10,
//     },
//     {
//         name: 'Theo',
//         years:5,
//     },
// ]

이때 만약 사용자가 근속 연수를 기준으로 다시 정렬한다면 무슨 일이 벌어질까요?
근속 연수로 두 번째 정렬을 하고 나면 처음과는 전혀 다른 결과가 나옵니다.

staff.sort(sortByYears);
// [
//     {
//         name: 'Theo',
//         years:5,
//     },
//     {
//         name: 'Dyan',
//         years:10,
//     },
//     {
//         name: 'Joe',
//         years:10,
//     },
// ]

이제 수백 명의 직원 목록에서 근속 연수가 동일한 직원이 여럿 있는 경우를 생각해봅시다.
사용자가 정렬 버튼을 누를 때마다 조금씩 순서가 바뀝니다.
매번 순서가 바뀐다면 사용자는 애플리케이션을 신뢰하지 못할 것입니다.

이를 해결하기 위해서는 원본 데이터를 조작하지 않으면 됩니다.
그 대신에 사본 을 만들고 사본을 조작합니다.
배열을 정렬하기 전에 원본 배열과 펼침 연산자로 새로운 배열을 만듭니다.

[...staff].sort(sortByYears);
// [
//     {
//         name: 'Theo',
//         years:5,
//     },
//     {
//         name: 'Joe',
//         years:10,
//     },
//     {
//         name: 'Dyan',
//         years:10,
//     },
// ]

원본 배열을 변경하지 않으므로 이제 사용자는 마음대로 정렬 기능을 사용할 수 있습니다.
또한, 같은 기준으로 정렬했을 때 항상 같은 결과를 확인할 수 있습니다.

profile
GSM - Front-end Developer

0개의 댓글