자바스크립트 코드 이제 TPO에 맞게 쓰세요, 클린 코드를 위한 36가지 팁

 

자바스크립트 코드 이제 TPO에 맞게 쓰세요, 클린 코드를 위한 36가지 팁

코딩을 하다 보면 '일단 돌아가게 만들자'는 생각에 급급할 때가 참 많은데요.

하지만 기능 구현에 성공한 뒤 코드를 다시 돌아보면, 어딘가 모르게 찜찜하고 비효율적으로 보이는 순간이 분명히 찾아옵니다.

마치 옷을 입을 때 시간(Time), 장소(Place), 상황(Occasion) 즉 TPO를 고려하는 것처럼, 우리 코드도 상황에 맞게 더 간결하고, 더 우아하고, 더 효율적으로 작성할 필요가 있거든요.

오늘은 제가 오랫동안 알고리즘 문제를 풀고 여러 프로젝트를 거치며 차곡차곡 모아온, 코드의 'TPO'를 제대로 맞춰주는 자바스크립트 팁들을 대방출하려고 합니다.

1부 가독성 한 스푼 코드는 짧고 의미는 명확하게

좋은 코드의 첫 번째 조건은 바로 '가독성'인데요.

나 자신과 미래의 동료들을 위해 코드는 최대한 간결하고 직관적으로 작성해야 합니다.

군더더기 else 블록 걷어내기

if-else if-else 구조가 길어지면 코드의 흐름을 파악하기가 힘들어지는데요.

각 조건이 독립적이라면, 조건을 만족할 때 바로 return 해버리는 '가드 클로즈(Guard Clause)' 패턴으로 훨씬 깔끔하게 만들 수 있습니다.

// Before
if (!l1) {
  return l2;
} else if (!l2) {
  return l1;
} else if (l1.val <= l2.val) {
  // ...some logic
} else {
  return l2;
}

// After: 각 조건이 명확히 보입니다.
if (!l1) return l2;
if (!l2) return l1;
if (l1.val <= l2.val) {
  // ...some logic
}
return l2;

여러 변수 한 번에 초기화하기

비슷한 성격의 변수들을 초기화할 때 여러 줄에 걸쳐 let을 선언하는 경우가 많은데요.

이걸 한 줄로 모아주면 코드의 시작점이 훨씬 깔끔해집니다.

// Before
let left = 0;
let current = 0;
let answer = 0;

// After
let left = current = answer = 0;

인덱스가 필요 없다면 for...of 사용하기

for 루프를 돌 때 인덱스 변수 i를 사용하지 않고 배열의 요소에만 접근한다면, for...of 구문을 쓰는 것이 훨씬 직관적이거든요.

코드의 의도가 '배열의 각 요소에 대해 작업을 수행하겠다'는 점을 명확하게 보여줍니다.

// Before: i는 여기서 아무 역할도 하지 않습니다.
for(let i = 0; i < queries.length; i++){
    res.push(limit > prefix[queries[i][1]] - prefix[queries[i][0]]);
}

// After: 훨씬 간결하고 의도가 명확합니다.
for(const query of queries){
    res.push(limit > prefix[query[1]] - prefix[query[0]]);
}

논리 연산자 할당으로 if문 대체하기

객체에 특정 키가 없을 때만 배열을 새로 할당해 주는 코드는 정말 자주 쓰이는데요.

||= (논리적 OR 할당) 연산자를 사용하면 이 if문을 단 한 줄로 줄일 수 있습니다.

const a = {};

// Before
if (a["a"] === undefined) {
  a["a"] = [];
}
a["a"].push(1);

// After: a["a"]가 falsy할 때만 빈 배열을 할당합니다.
a["a"] ||= [];
a["a"].push(1);

2부 자료구조 120% 활용하기

상황에 맞는 자료구조를 선택하고 그 특성을 잘 활용하면, 코드의 성능과 가독성을 동시에 잡을 수 있습니다.

존재 여부 체크는 Set에게 맡기기

배열에서 특정 요소의 존재 여부를 확인하기 위해 includes를 사용하는 건 데이터가 많아질수록 성능에 부담이 되는데요.

Set은 내부적으로 해시 테이블로 구현되어 있어 요소 탐색이 평균적으로 O(1)의 시간 복잡도를 가집니다.

// Before: includes는 배열을 순회하므로 느릴 수 있습니다.
if (someArray.includes(value)) { /* ... */ }

// After: Set을 미리 만들어두면 has는 거의 즉시 결과를 반환합니다.
const someSet = new Set(someArray);
if (someSet.has(value)) { /* ... */ }

Map으로 빈도수 계산을 우아하게

배열에 있는 요소들의 빈도를 세야 할 때가 정말 많은데요.

이럴 때 보통 Map을 사용하는 것이 정석입니다.

하지만 if문으로 키의 존재 여부를 확인하고 값을 업데이트하는 코드는 조금 길고 장황하게 느껴지거든요.

이럴 때 || 연산자를 활용하면 코드를 아주 우아하게 다듬을 수 있습니다.

const nums = [1, 2, 1];
const map = new Map();

// Before
for (const n of nums) {
  if (map.has(n)) {
    map.set(n, map.get(n) + 1);
  } else {
    map.set(n, 1);
  }
}

// After: `map.get(n)`이 없으면 0을 기본값으로 사용합니다.
for (const n of nums) {
  map.set(n, (map.get(n) || 0) + 1);
}

Map 정렬은 배열로 변환해서

Map은 키의 순서를 보장하지만, 값이나 키를 기준으로 정렬하는 내장 메서드는 없는데요.

... 스프레드 문법과 entries()를 이용해 배열로 변환한 뒤, sort() 메서드를 적용하고 다시 Map으로 만들어주면 됩니다.

const map = new Map([['b', 2], ['a', 1], ['c', 3]]);

// 값을 기준으로 오름차순 정렬
const sortedByValue = new Map([...map.entries()].sort(([, v1], [, v2]) => v1 - v2));
// [['a', 1], ['b', 2], ['c', 3]]

// 키를 기준으로 오름차순 정렬
const sortedByKey = new Map([...map.entries()].sort(([k1], [k2]) => k1.localeCompare(k2)));
// [['a', 1], ['b', 2], ['c', 3]]

2차원 배열 쉽게 만들기

for 루프를 돌며 2차원 배열을 만드는 건 이제 옛날 방식인데요.

Array.from이나 Array.prototype.map을 활용하면 훨씬 선언적으로 2차원 배열을 생성할 수 있습니다.

const rows = 3;
const cols = 4;

// Good: Array.from 사용
const dp1 = Array.from({ length: rows }, () => Array(cols).fill(0));

// Good: fill과 map의 조합
const dp2 = Array(rows).fill(0).map(() => Array(cols).fill(0));

3부 성능 최적화와 마이크로 팁

이제 코드의 실행 속도를 높여주는 몇 가지 기술적인 팁들을 알아볼 텐데요.

사소해 보이지만 반복문 안에서 큰 차이를 만들어낼 수 있습니다.

이진 탐색 시 오버플로우 방지하기

이진 탐색 알고리즘에서 중간 인덱스를 계산할 때 (left + right) / 2를 흔히 사용하는데요.

leftright가 아주 큰 숫자일 경우 두 수를 더하는 과정에서 오버플로우가 발생할 수 있습니다.

left + (right - left) / 2와 같이 식을 바꿔주면 이 문제를 안전하게 피할 수 있습니다.

// Before: left와 right가 매우 크면 오버플로우 위험이 있습니다.
let mid = Math.floor((right + left) / 2);

// After: 안전한 계산 방식입니다.
let mid = Math.floor(left + (right - left) / 2);

비트 연산으로 성능 끌어올리기

곱하기 2나 나누기 2 연산은 비트 시프트(shift) 연산으로 대체하면 성능상 이점을 볼 수 있거든요.

또한, Math.floor() 대신 비트 OR 연산 | 0이나 이중 NOT 연산 ~~을 사용하면 소수점 아래를 버리고 정수로 매우 빠르게 변환할 수 있습니다.

다만 이 방법들은 32비트 정수 범위 내에서만 정확하게 동작하니 주의해야 합니다.

// 2를 곱하는 것보다 빠릅니다.
let num = 5;
num = num << 1; // 10

// Math.floor(5.75)보다 빠릅니다.
const intNum1 = 5.75 | 0; // 5
const intNum2 = ~~5.75;  // 5

숫자의 자릿수는 log 연산으로

숫자의 자릿수를 구하기 위해 toString().length를 사용하는 건 정말 편리한데요.

하지만 이 방법은 내부적으로 숫자를 문자열로 변환하는 과정 때문에 생각보다 비용이 큽니다.

수학적인 접근법인 Math.log10()을 사용하면 O(1)의 시간 복잡도로 훨씬 빠르게 자릿수를 계산할 수 있습니다.

const n = 12345;

// Bad: 문자열 변환 비용이 발생합니다.
const len1 = n.toString().length; // 5

// Good: 수학적으로 바로 계산하여 빠릅니다.
const len2 = Math.floor(Math.log10(n)) + 1; // 5

단 하나의 유니크한 값 찾기 (XOR 활용)

배열에서 단 하나의 요소를 제외하고 모든 요소가 두 번씩 짝지어 나타날 때, 그 유니크한 요소를 찾는 문제가 있는데요.

이럴 때 XOR(^) 비트 연산자를 사용하면 마법 같은 일이 벌어집니다.

a ^ a = 0이고 a ^ 0 = a라는 XOR의 성질을 이용하면, 모든 요소를 쭉 XOR 연산했을 때 중복된 값들은 모두 0이 되고 마지막에 유니크한 값만 남게 됩니다.

const nums = [4, 1, 2, 1, 2];
let result = 0;
for (const num of nums) {
  result ^= num;
}
// result는 4가 됩니다. O(1)의 공간 복잡도로 해결!

4부 알면 힘이 되는 고급 트릭 몇 가지

마지막으로, 자주 사용되진 않지만 알아두면 특정 상황에서 아주 유용하게 쓰일 수 있는 몇 가지 고급 기술들입니다.

중첩 루프 탈출은 라벨과 함께

for 루프가 이중, 삼중으로 중첩되어 있을 때, 가장 안쪽 루프에서 특정 조건을 만족하면 한 번에 바깥 루프까지 탈출하고 싶을 때가 있는데요.

이럴 때 '라벨(label)'을 사용하면 break 문으로 원하는 루프를 정확히 지정하여 탈출할 수 있습니다.

outerLoop: for (let i = 0; i < 3; i++) {
  for (let j = 0; j < 3; j++) {
    if (i === 1 && j === 1) {
      break outerLoop; // 'outerLoop' 라벨이 붙은 바깥 루프를 탈출합니다.
    }
    console.log(`i = ${i}, j = ${j}`);
  }
}

FizzBuzz 문제, 한 줄로 풀어보기

코딩 인터뷰 단골 문제인 FizzBuzz도 아주 기발한 방법으로 풀 수 있거든요.

보통 if-else 문으로 풀지만, 논리 연산자와 나머지 연산자를 조합하면 한 줄짜리 코드로도 구현이 가능합니다.

const fizzBuzz = (n) => {
  return new Array(n).fill(0).map((_, i) => (++i % 3 ? '' : 'Fizz') + (i % 5 ? '' : 'Buzz') || '' + i);
};

물론 가독성 측면에서는 논쟁이 있을 수 있지만, 자바스크립트의 유연성을 보여주는 재미있는 예시입니다.

sort() 메서드의 함정을 피하자

자바스크립트 배열의 sort() 메서드는 정말 편리하지만 치명적인 함정이 하나 있는데요.

별도의 비교 함수를 전달하지 않으면 모든 요소를 '문자열'로 취급하여 정렬한다는 점입니다.

따라서 숫자를 정렬할 때는 반드시 (a, b) => a - b와 같은 비교 함수를 전달해야 원하는 결과를 얻을 수 있습니다.

const numbers = [10, 5, 100, 2];

// Bad: 문자열 기준으로 정렬되어 [10, 100, 2, 5]가 됩니다.
numbers.sort(); 

// Good: 숫자 크기 기준으로 올바르게 정렬됩니다.
numbers.sort((a, b) => a - b); // [2, 5, 10, 100]

코드를 다듬는 여정을 마치며

오늘 정말 많은 팁들을 살펴봤는데요.

중요한 것은 이 모든 팁을 무조건 암기해서 사용하는 것이 아닙니다.

각 기술이 어떤 원리로 동작하고, 어떤 상황에서 가장 효과적인지, 그리고 가독성과 성능 사이에서 어떤 트레이드오프가 있는지를 이해하는 것이 핵심이거든요.

오늘 배운 팁들을 여러분의 코드에 하나씩 적용해보면서, 더 나은 코드를 향한 즐거운 여정을 계속해나가시길 바랍니다.