질문
자바스크립트 자료구조 중에서 array, object는 여러 데이터를 그룹화하는 두 가지 방법이라는 면에서 서로 유사하면서도 근본적으로 다르다. 그렇다면 둘 중 무엇이 더 효율적이고 좋은 자료구조일까?
원형으로서의 객체
자바스크립트에서는 모든 것이 원형(prototype) 객체를 가진 객체이다. 그렇다 보니 배열과 객체를 비교하기 전에, 객체의 정의를 명확히 하고자 한다. 자바스크립트에서 배열을 하나 선언하면 이 배열은 Array.prototype
에서 배열 관련 프로퍼티를 상속한다. 이제 해당 배열의 길이 값과 흔히 배열을 조작하는 데 쓰이는 배열 순회 메소드들에 접근이 가능해진다. 객체의 특정 프로퍼티를 참조하는 방법과 같이 dot annotation을 사용하면 된다.
// 배열 길이 참조
arr.length;
// 배열 순회 메소드 호출
arr.filter((item) => item.id === 1);
이 배열의 내장 프로퍼티들은 임의로 확장될 수도 있다. 객체에 값을 할당하듯이 프로토타입에 값을 정의하여 할당하면 된다.
Array.prototype.newMethod = function () {
// 임의 로직 작성
};
이같은 활용은 실무에서 권장되는 방식은 아니지만 배열이 왜 객체와 같이 동작하는지 이해하는 예시로는 적절할 수도 있다. 결국 자바스크립트에서 배열 역시 객체라고 할 수 있는데, 이때 객체는 일반적인 자료구조로서의 객체라기보다도 자바스크립트라는 프로그래밍 언어의 맥락에서 사용되는 원형으로서의 객체를 의미한다.
자료구조로서의 배열과 객체
한 단계 더 추상화된 원형으로 들어가 보면 배열은 iterator 속성을 상속한다. 배열뿐 아니라 String, TypedArray 등의 유사 배열, 또는 Map, Set 등 프로토타입 체인 상에서 iterator를 상속하는 모든 객체들을 iterable이라 일컫는다. iterable 객체에서는 for loop로 요소를 순회하거나 spread 연산자, 구조분해 할당 등을 사용할 수 있다. (MDN- Iteration protocols) 객체를 다룰 때에도 이러한 문법들은 공통적으로 지원된다. 그 이유는 해당 자료형들이 모두 자료를 그룹화하는 특징을 가지고 있기 때문이다. 근본적인 차이점을 찾는다면 배열에는 순서가 있다는 점이다. 객체가 요소를 키값으로 인덱싱한다면 배열은 요소의 순서에 해당되는 숫자값이 인덱스 역할을 한다. 이 차이로 인해서 각각의 유즈케이스가 나뉘기도 하고, 때로는 정해진 자료구조를 다른 자료구조로 컨버팅해야 하는 상황을 맞닥뜨리기도 한다.
배열의 객체화
Object.fromEntries([
[key1, value1],
[key2, value2],
]); // { key1: value1, key2: value2 }
[value1, value2].reduce(
acc,
cur,
(idx) => ({
...acc,
[`key${idx + 1}`]: cur,
}),
{},
); // { key1: value1, key2: value2 }
객체의 배열화
Object.entries({
key1: value1,
key2: value2,
}); // [[key1, value1], [key2, value2]]
이러한 동작들은 필요에 따라 유용하기도 하지만 컨버팅 로직이 많아질수록 복잡도를 높이는 원인이 되기도 해서, 코드가 복잡해지는 느낌이 든다면 애초에 자료구조의 선택이 적절했는지 한 번쯤 검토해보는 것이 좋을 수도 있다.
Use Case 1
비즈니스 로직 관점에서 같은 데이터를 배열과 객체로 다르게 표현했을 때 어떤 차이가 발생하는지 보고자 한다. 예를 들어 담당자별 연락처를 수집해야 하는 상황이라고 생각해 보면, 연락처 데이터 자체는 동일한 형태의 반복이겠지만 담당자 정보는 데이터별로 다를 것이다.
담당자별 연락처를 배열로 표현
const contactsArr = [
{
type: 'representative',
contact: '+82-111-1111',
name: '김대표',
},
{
type: 'cs',
contact: '+82-222-2222',
name: '이대표',
},
{
type: 'product',
contact: '+82-333-3333',
name: '박대표',
},
];
배열 구조에서는 type이라는 필드가 각 항목마다 포함되어 있다. 이런 경우에는 항목을 순회하여 컴포넌트를 그리거나, 새로운 담당자 타입의 연락처를 추가하는 등 연락처 목록의 개수 및 순서가 조작되어야 할 때 별다른 컨버팅 없이 로직 작성이 가능하다. 만약 기획상 연락처의 추가, 삭제 등이 요구사항에 포함되어 있다면 배열 구조를 채택하는 것이 나을 수 있다.
담당자별 연락처를 객체로 표현
const contactsObj = {
representative: {
contact: '+82-111-1111',
name: '김대표',
},
cs: {
contact: '+82-222-2222',
name: '이대표',
},
product: {
contact: '+82-333-3333',
name: '박대표',
},
};
반대로 객체 구조에서는 type 정보가 키값이며, 키에 맵핑되는 데이터가 연락처 정보이다. 배열의 경우 순서값으로만 인덱싱되기 때문에 담당자 타입으로 특정 연락처 항목을 찾으려면 반드시 필터링 과정을 거쳐야 한다. 만약 객체라면 키값을 통해 바로 접근할 수 있다. 담당자 종류가 고정되어 있고 연락처가 추가, 삭제될 가능성이 없으며 오히려 특정 담당자 타입을 골라 수정하는 기능이 주를 이룬다면 객체 구조를 채택하는 것이 나을 수 있다.
Use Case 2
또한 사소한 것 같으면서도 생각보다 실무에서 자주 고민하게 되는 포인트는 함수의 인자를 어떻게 정의할 것인지이다. 특히 함수의 인자가 여러 개 필요한 경우에 배열과 객체 중 어떤 형태로 표현하는지에 따라 장단점이 나뉜다. 대부분의 경우는 함수가 명확하게 하나의 역할만 담당하도록 쪼갬으로써 인자 수를 줄일 수 있고, 이런 방향으로 리팩토링하는 것이 바람직하다. 하지만 하나의 역할만 담당하는 함수라도 여전히 여러 개의 인자를 받아야 하는 경우에는 적합한 자료구조를 선택해야 한다.
함수의 인자를 배열로 표현
자바스크립트에서 함수의 인자는 기본적으로 유사 배열이다. length 속성 외의 배열 메소드가 지원되지는 않지만, iterable이며 순서가 정해져 있다. 이런 특징을 필요로 하는 함수라면 인자를 배열로 정의하는 것이 좋다. 예를 들어 인자의 타입은 하나로 고정되어 있지만 개수는 정해져 있지 않고, 내부에서 인자를 순회하는 로직을 수행하는 경우를 들 수 있다. 문자열 배열을 인자로 받아 대문자로 바꾸고 쉼표 구분자로 연결하는 함수를 작성해 보자.
function upperAndConcat(...args: string[]) {
return args.map((arg) => arg.toUpperCase()).join(', ');
}
upperAndConcat('a', 'b', 'c'); // 'A, B, C'
upperAndConcat('apple', 'banana', 'orange', 'kiwi'); // 'APPLE, BANANA, ORANGE, KIWI'
하지만 실무에서는 이처럼 처음부터 동적인 배열 형태로 인자를 다루기 위한 목적이 아니라, 유지보수 과정에서 함수가 참조해야 할 데이터가 늘어남으로 인해 수동으로 하나씩 인자를 추가하게 되면서 종종 문제가 생긴다. 이런 과정을 통해 함수의 역할이 비대해지고 인자의 필수값 여부에 대한 혼란이 야기된다. 이전까지 마지막 순서였던 인자가 옵셔널한 값이었는데 새로 추가한 인자는 반드시 필요한 값이라면, 사용처의 코드를 수정하거나 가독성을 포기해야 하는 결과로 이어진다. 그마저도 코드베이스가 타입스크립트로 되어 있다면 타입 에러로 인해 어쩔 수 없이 인자 순서를 바꾸어야 한다. 여러 모로 유지보수 경험이 좋지 않은 선택이 될 수 있다.
// ERROR: A required parameter cannot follow an optional parameter.ts(1016)
function legacyFn(first: string, second?: number, thrid: boolean) {
// ...
}
legacyFn('hi', 123); // 레거시 코드 에러 발생 (신규로 추가된 세번째 인자 없음)
legacyFn('hello', undefined, true); // 신규 코드
함수의 인자를 객체로 표현
개인적으로는 위와 같은 이유로 함수의 인자가 늘어나는 조짐을 보일 때, 함수의 역할이 비대해지고 있는지 살펴보고 그런 문제가 아니라면 인자를 객체화하는 편이다. 인자를 객체화하면 순서에 구애받지 않으면서 옵셔널과 필수값 여부를 쉽게 제어할 수 있다. 또한 사용처에서 인자의 키값을 명시하기 때문에 가독성 측면에서도 더 낫다. 물론 함수의 네이밍을 명확히하고 인자를 최소화하는 방법이 이상적이지만 어쩔 수 없이 인자가 많아지는 경우에는 객체화하는 것을 고려해보면 좋다.
리액트의 함수형 컴포넌트를 예로 들면, 이 함수는 컴포넌트를 렌더링하는 역할 하나만을 담당하지만 역할에 따라 props를 많이 필요로 할 수도 있다. HTML 속성을 상속하는 컴포넌트라면 해당 속성들이 전부 prop에 포함된다. 따라서 리액트의 함수형 컴포넌트는 prop을 객체로 전달받는다.
function Component({ className, onClick, children }: ComponentProps<'div'>) {
return (
<div className={className} onClick={onClick}>
{children}
</div>
);
}
데이터를 그룹화하는 다른 방법들
배열과 객체가 데이터를 그룹화하기 위해서 개발시 가장 흔하게 사용하는 자료구조이긴 하지만, 그 외의 다른 방법들도 있다. 특정 유즈케이스에 대해서는 다음의 자료구조가 훨씬 적절한 선택일 수 있으므로 알아두고 설계시 참고하면 좋을 것 같다.
Set
Set는 배열과 비슷하지만 순서로 인덱싱하지 않으며 중복을 허용하지 않는다. Set는 요소의 존재 여부를 확인하는 데 최적화된 자료구조이다. 배열의 경우 includes
메소드로 요소의 존재 여부를 찾는데 배열 크기만큼 시간이 걸리지만 세트의 has
메서드는 일반적으로 훨씬 빠르다. 다만 작은 크기의 데이터로는 벤치마크상의 차이가 유의미할 정도로 크지 않다. 중복을 제거하고 싶거나, 데이터 수가 유의미하게 많은데 빠르게 검색하고 싶은 경우 채택할 수 있다. 또한 Set가 순서 없는 집합이라고 표현되기도 하는데 인덱스가 없기 때문에 특정 순서의 요소가 무엇인지 알기는 어렵다. 다만 요소가 삽입된 순서를 기억하고는 있기 때문에 Set를 순회할 때 항상 순서대로 순회한다.
let s = new Set(); // 세트 선언
s.add(1);
console.log(s.size); // 1
s.add(1);
console.log(s.size); // 1 (같은 요소를 추가했으나 사이즈는 그대로)
s.add([1, 2, 3]);
console.log(s.size); // 2 (배열은 개별 요소가 아닌 하나의 요소로 추가됨)
s.delete(1);
console.log(s.size); // 1 (요소 1이 삭제됨)
s.clear();
console.log(s.size); // 0 (모든 요소가 제거됨)
Map
Map도 배열, 객체와 비슷하게 키로 구성된 값 집합이다. 다만 배열이 순서에 해당되는 연속된 정수를 키로 사용하고, 객체가 문자열을 키로 사용하는 제약이 있다면 Map은 임의의 값을 사용할 수 있다. null
, undefined
, NaN
, 객체, 배열 등의 자바스크립트의 어떤 값도 키로 사용 가능하다. 다만 키를 비교할 때 동등성이 아닌 일치성으로 판단하기 때문에 객체나 배열을 키로 사용했다면 프로퍼티와 요소가 정확히 일치하더라도 다른 것으로 판단한다.
Map 객체를 만들면 get
과 set
을 통해 키와 연결된 값을 검색하거나 추가할 수 있다. Set와 유사하게 has
메소드로 요소의 존재 여부를 확인하고 삽입된 순서대로 순회한다.
let m = new Map();
m.set('one', 1);
m.set('two', 2);
console.log(m.size); // 2
m.get('three'); // undefined
m.set('one', true); // 기존 값 변경
console.log(m.size); // 2 (크기는 그대로)
m.has(true); // false (키값으로서 true는 존재하지 않음)
m.delete('one');
console.log(m.size); // 1 (요소 'one'이 삭제됨)
m.clear(); //
console.log(m.size); // 0 (모든 요소가 제거됨)
성능
그룹화된 데이터의 경우 해당 그룹에 값을 삽입 또는 삭제하는 동작, 그리고 그룹 중 특정 값을 꺼내오거나 업데이트하는 동작을 자주 수행한다. 기본적인 동작 수행에서는 객체가 O(1)의 시간 복잡도를 가지므로 값의 위치에 따라 O(n) 시간 복잡도를 가질 수 있는 배열보다 빠르다. 순서가 중요하지 않은 데이터라면 객체로 작업하는 것이 유리하다고 짐작할 수 있다. 그러나 값에 접근하는 동작의 성능은 배열과 객체가 대동소이한 결과를 나타내는데, 자바스크립트 자체적으로 배열을 최적화하기도 하고 숫자값으로 인덱스되어 있기 때문이다. 배열 순환 메소드는 필연적으로 O(n) 시간 복잡도를 가진다. 다만 프론트엔드에서 아이템 배열을 순환하여 컴포넌트를 그리는 패턴처럼 실무에서 순환 메소드는 일반적으로 많이 쓰인다. 성능을 이유로 무엇이 더 좋은 자료구조라고 단정짓기보다는 해당 데이터가 비즈니스 로직에서 어떤 역할을 하는지 파악하는 것이 우선되어야 할 것이다.
노트
- 배열의 sort 메소드는 Unicode를 기반으로 정렬을 수행한다. 따라서 원하는 comparator 함수를 인자로 넘겨주는 것이 일반적이다.
- 특히 객체들의 배열을 정렬하는 방법에 대해 새롭게 알게 됐다.
localeCompare
- 특히 객체들의 배열을 정렬하는 방법에 대해 새롭게 알게 됐다.
// array of strings in a uniform case without special characters
const arr = ['name', 'age', 'job'];
arr.sort(); //returns ["age", "job", "name"]
// array of numbers
const arr = [30, 4, 29, 19];
arr.sort((a, b) => a - b); // returns [4, 19, 29, 30]
// array of number strings
const arr = ['30', '4', '29', '19'];
arr.sort((a, b) => a - b); // returns ["4", "19", "29", "30"]
// array of mixed numerics
const arr = [30, '4', 29, '19'];
arr.sort((a, b) => a - b); // returns ["4", "19", 29, 30]
// array of non-ASCII strings and also strings
const arr = ['réservé', 'cliché', 'adieu'];
arr.sort((a, b) => a.localeCompare(b)); // returns is ['adieu', 'cliché','réservé']
// array of objects
const arr = [
{ name: 'Sharpe', value: 37 },
{ name: 'And', value: 45 },
{ name: 'The', value: -12 },
];
// sort by name string
arr.sort((a, b) => a['name'].localeCompare(b['name']));
// sort by value number
arr.sort((a, b) => a['value'] - b['value']);
- 객체에는 sorting을 위한 내장 메소드가 없다.
- 다만 ES6에서는 객체를 생성할 때 키값을 정렬하는 기능이 내장되어 있다. (numeric/numeric-string에 한해서)
// object with numeric/numeric-string keys are sorted
const obj = { 30: 'dad', 4: 'kid', 19: 'teen', 100: 'grams' };
console.log(obj); // returns {4: "kid", 19: "teen", 30: "dad", 100: "grams"} with sorted keys
// object with key-values as alpha-strings are not sorted
const obj = { b: 'two', a: 'one', c: 'three' };
console.log(obj); // returns {b: "two", a: "one", c: "three"}
// object with numeric, numeric-string and alpha keys are partially sorted. (i.e only numeric keys are sorted)
const obj = { b: 'one', 4: 'kid', 30: 'dad', 9: 'son', a: 'two' };
console.log(obj); // returns {4: "kid", 9: "son", 30: "dad", b: "one", a: "two"}