6월 한 달간 프로그래머스 문제를 풀며 작성한 AI 코드 리뷰들을 다시 펼쳐봤다. 한 달치를 모아놓고 보니 내가 반복적으로 하는 실수와, 반대로 한 달 사이에 분명히 나아진 부분이 선명하게 드러났다. 이번 회고에서는 그 패턴들을 정리해보려고 한다.
가장 자주 나오는 것은 무한 루프를 제어하는 방식이었다. 종료 조건을 while문 조건식에 직접 넣지 않고, while (answer === 0)이나 while (true) 뒤에 break를 거는 식으로 우회하는 습관이 있었다. '다음 큰 수', '카펫', '콜라 문제'에서 공통적으로 '무한 루프 위험'이라는 피드백을 받았다. 종료 조건은 가능하면 조건식에 그대로 드러내는 게 안전하고, 코드만 봐도 언제 끝나는지 명확해진다는 것을 배웠다.
// 종료 조건이 숨어있는 방식
while (answer === 0) { /* ... */ }
// 종료 조건이 한눈에 보이는 방식
while (left <= right) { /* ... */ }
두 번째는 원본 배열을 직접 바꾸는 습관이었다. sort()와 reverse()가 원본을 변경한다는 걸 자주 놓쳤다. '최소직사각형', '게임 맵 색칠', '두 개 뽑아서 더하기' 문제에서 같은 지적이 나왔다. 특히 함수 인자로 받은 배열은 외부와 공유되는 참조일 수 있어서, 무심코 정렬하면 호출한 쪽 데이터까지 바뀐다는 점이 위험했다. 비록 코딩테스트에서는 큰 문제가 아니지만, 이제는 복사본을 만들고 작업하는 습관을 들이려고 한다.
const sorted = [...arr].sort((a, b) => a - b); // 원본 보존
세 번째는 변하지 않는 값을 루프 안에서 매번 계산하는 습관이었다. '다음 큰 수'에서 'n의 2진수 1의 개수'를 반복문 안에서 계속 구했고, '카펫'에서 (brown + 4) / 2를 매번 다시 계산했다. 루프를 돌 때마다 결과가 똑같은 연산은 밖으로 빼야한다는, 어찌 보면 당연한 걸 자꾸 놓쳤다.
마지막은 변수명이었다. temp, a, i 같은 범용 이름을 너무 자주 썼다. '구명보트'의 left/right, '끝말잇기'의 idx, '콜라'의 newCola/remainBottles처럼 역할이 드러나는 이름을 쓰라는 피드백이 반복됐다. 변수명만 좋아도 주석 없이 로직이 읽힌다는 걸 체감했다.
반대로, 다시 보니 점점 발전한 지점도 있었다.
가장 인상적이었던 건 '구명보트' 문제다. 처음엔 slice와 pop으로 풀어서 가 됐고 효율성 테스트에서 떨어졌는데, 그걸 겪고 나서 직접 left/right 투 포인터로 풀이를 다시 완성했다. 시간 초과를 만나고 접근 자체를 바꾼 경험이라 기억에 남는다.
자료구조를 바꿔서 푸는 감각도 붙었다. '짝지어 제거하기'에서는 재귀(시간초과)를 스택으로 개선했다.
'명예의 전당' 문제도 기억에 남는다. AI 피드백으로 받은 pop() 활용 풀이가 코드는 더 깔끔했지만, 성능은 기존 코드가 미세하게 낫다는 것을 짚어냈다. 가독성과 성능 사이엔 트레이드오프가 있다는 걸 구분하게 된 것 같다.
이번 달에 처음 제대로 써본 개념도 몇 가지 있었다.
비트 연산은 '1의 개수 세기'류 문제에서 핵심이었다. n & 1로 마지막 비트를 확인하고, n >> 1로 2로 나누는 식이다. 다만 >>가 ÷ 2와 같다는 건 양수일 때만 정확하고, 부호 없는 이동인 >>>와는 동작이 다르다는 점은 따로 메모해뒀다.
5 & 1; // 1 (홀수 판별)
5 >> 1; // 2 (5 / 2 내림, 양수 기준)
BigInt는 정수 정밀도 문제에서 처음 썼다. JavaScript가 안전하게 다루는 최대 정수는 약 9×10¹⁵(2⁵³-1)인데, 그보다 큰 정수를 비교하려니 일반 Number로는 부정확해졌다. 왜 큰 수 비교가 어긋나는지를 직접 겪어본 게 컸다.
유클리드 호제법은 최대공약수·최소공배수 문제에서 두어 번 나왔다. 두 수의 GCD를 빠르게 구하는 방식인데, 재귀 버전은 간결한 대신 입력이 아주 커지면 스택 오버플로우 가능성이 있다는 점도 같이 익혔다.
const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
DFS로 조합을 만드는 방식도 새로웠다. 학생 3명의 정수 번호를 더했을 때 0이 되는 경우를 구하는 '삼총사' 류 문제에서, 삼중 for문 대신 '현재 원소를 포함하는 경우와 포함하지 않는 경우'로 나누는 재귀 패턴을 봤다. 확장성이 좋다는 건 이해했지만, 아직 손에 익진 않아서 다음 달에 조합·순열 문제로 더 연습하면 좋을 것 같다.
function solution(number) {
let count = 0;
function combine(index, currentSum, depth) {
// 3명을 모두 뽑았을 때
if (depth === 3) {
if (currentSum === 0) count++;
return;
}
// 배열 끝까지 탐색했을 때
if (index === number.length) return;
// 현재 학생을 포함하는 경우
combine(index + 1, currentSum + number[index], depth + 1);
// 현재 학생을 포함하지 않는 경우
combine(index + 1, currentSum, depth);
}
combine(0, 0, 0);
return count;
}
설명만 적어두면 막상 풀 때 기억이 안 나서, 짧은 예시 코드와 함께 다시 정리해뒀다.
localeCompare: 문자열 사전순 정렬 기준을 만들 때 음수/양수/0을 반환한다.
["banana", "apple"].sort((a, b) => a.localeCompare(b)); // ["apple", "banana"]
toString(2): 숫자를 2진 문자열로 바꾼다.
parseInt(str, 2): 2진 문자열을 숫자로 바꾼다.
(10).toString(2); // "1010"
parseInt("1010", 2); // 10
replaceAll vs replace(/.../g)replaceAll과 replace(/.../g): 모든 항목을 바꾸려면 replaceAll이나 정규식 g 플래그가 필요하다. g 없는 replace는 첫 번째 하나만 바꾼다.
"a-b-c".replace("-", "+"); // "a+b-c"
"a-b-c".replaceAll("-", "+"); // "a+b+c"
padStart / repeatpadStart: 길이를 맞춘다.
repeat: 문자열을 반복할 때 사용한다.
"5".padStart(3, "0"); // "005"
"*".repeat(3); // "***"
Object.values / Map객체의 값만 배열로 뽑을 때 Object.values, 키 타입이 다양하거나 순서 보장이 필요하면 Map을 사용하자.
Object.values({ a: 1, b: 2 }); // [1, 2]
match는 못 찾으면 null 반환match는 일치하는 게 없으면 null을 반환한다는 점도 기억해둬야 한다. null에 .length를 쓰면 에러가 나서 || []로 막아둬야 안전하다.
("abc".match(/1/g) || []).length; // 0
g 플래그 없을 때: 첫 번째로 일치하는 것 하나만 찾고, 그 상세 정보(인덱스, 원본 문자열)까지 같이 돌려준다.
"banana".match(/a/);
// ["a", index: 1, input: "banana", groups: undefined]
g 플래그 있을 때: 일치하는 것을 전부 찾아서 값만 배열로 돌려준다.
"banana".match(/a/g);
// ["a", "a", "a"]
한 달 치를 모아놓고보니, 지금 나는 '동작하는 코드'에서 '효율적이고 읽기 좋은 코드'로 넘어가는 전환점에 있는 것 같다. 시간 초과를 겪고 스스로 자료구조를 바꿔본 경험들이 그 증거다.
다음 달에는 두 가지를 의식적으로 챙겨보려고 한다. 하나는 무한 루프를 조건식으로 명확히 제어하는 것, 다른 하나는 원본 배열을 함부로 바꾸지 않는 것. 이 두 습관만 잡아도 코드 품질이 눈에 띄게 올라갈 것 같다. 그리고 아직 손에 익지 않은 DFS 조합 패턴도 더 연습해볼 생각이다.