
알고리즘을 처음 공부할 때는 비슷한 유형의 문제를 반복해서 푼다.
배열에서 중복된 값을 찾는 문제라면 다음과 같은 코드를 배울 수 있다.
function hasDuplicateCouponIds(couponIds) {
const seenCouponIds = new Set();
for (const couponId of couponIds) {
if (seenCouponIds.has(couponId)) {
return true;
}
seenCouponIds.add(couponId);
}
return false;
}
이미 확인한 쿠폰 ID를 Set에 저장하고, 같은 ID가 다시 등장하면 중복이 있다고 판단한다.
중복 탐지 문제의 기본적인 해결 방법을 이해하기에는 좋은 예제다.
하지만 시험이나 코딩 테스트를 준비하다 보면 코드를 하나의 공식처럼 외우기 쉽다.
중복을 찾는 문제에서는
Set을 사용한다.
이 공식은 익숙한 문제에서는 도움이 된다. 문제에 ‘중복’이라는 단어가 나오면 이전에 외운 코드를 다시 작성할 수 있기 때문이다.
그런데 문제의 표현이 조금만 달라지면 상황이 달라진다.
모두 중복과 관련된 문제지만 같은 Set 코드로 해결할 수 있는 것은 아니다.
그렇다면 알고리즘을 공부할 때 실제로 배워야 하는 것은 무엇일까?
알고리즘을 공부한다는 것은 풀이 코드를 저장하는 일이 아니라, 그 코드가 제거한 반복 작업과 유지한 정보를 발견하여 새로운 문제에 다시 적용하는 과정이다.
쿠폰 사용 요청 목록에서 중복된 요청을 찾는다고 생각해보자.
가장 직접적인 방법은 각 요청을 다른 모든 요청과 비교하는 것이다.
function hasDuplicateRequests(requests) {
for (let current = 0; current < requests.length; current++) {
for (let next = current + 1; next < requests.length; next++) {
if (requests[current].requestId === requests[next].requestId) {
return true;
}
}
}
return false;
}
이 코드는 동작한다.
첫 번째 요청을 나머지 요청과 비교하고, 두 번째 요청도 그다음 요청들과 비교한다. 같은 requestId가 발견되면 중복이라고 판단한다.
하지만 요청이 많아질수록 같은 데이터를 반복해서 확인한다.
요청이 10개일 때는 큰 문제가 아닐 수 있다. 요청이 수십만 개라면 비교 횟수가 빠르게 늘어난다.
여기서 단순히 “중첩 반복문을 Set으로 바꿔야 한다”라고 외우면 코드만 바뀐다. 더 중요한 질문은 다음과 같다.
왜 같은 데이터를 반복해서 비교해야 하는가?
현재 요청이 이전에 등장했는지 판단하려면 지금까지 확인한 모든 요청을 다시 살펴봐야 하기 때문이다.
그렇다면 다음 질문이 생긴다.
이전에 확인한 요청 ID를 기억해두면 다시 탐색할 필요가 없지 않을까?
이 질문에서 Set을 사용한 해결 방법이 나온다.
function hasDuplicateRequests(requests) {
const processedRequestIds = new Set();
for (const request of requests) {
if (processedRequestIds.has(request.requestId)) {
return true;
}
processedRequestIds.add(request.requestId);
}
return false;
}
첫 번째 코드와 두 번째 코드의 차이는 단순히 반복문 개수에 있지 않다.
두 번째 코드는 지금까지 확인한 정보를 저장하여 같은 범위를 다시 탐색하지 않는다.
이것이 다른 문제에서도 다시 사용할 수 있는 해결 원리다.
Set은 해결책의 출발점이 아니라, 필요한 정보를 저장하기 위해 선택한 도구다.
중복 요청을 발견하려면 현재 요청에 대해 다음 질문에 답할 수 있어야 한다.
이 요청 ID를 이전에 확인한 적이 있는가?
이 질문에 답하려면 지금까지 등장한 요청 ID를 기억해야 한다.
const processedRequestIds = new Set();
이 변수에는 아직 처리하지 않은 요청이나 전체 쿠폰 정보가 저장되지 않는다. 중복 여부를 판단하는 데 필요한 requestId만 저장된다.
processedRequestIds.add(request.requestId);
요청 하나를 확인할 때마다 이후 판단에 필요한 정보를 추가한다.
if (processedRequestIds.has(request.requestId)) {
return true;
}
현재 ID가 이미 저장되어 있다면 이전에 같은 요청이 등장했다는 뜻이다.
여기서 공부해야 할 핵심은 Set의 문법이 아니다.
이 사고방식을 이해하면 문제에 Set이라는 단어가 없어도 같은 원리를 발견할 수 있다.
쿠폰 서비스에는 중복 요청 외에도 이미 확인한 정보를 기억해야 하는 문제가 많다.
예를 들어 사용자의 쿠폰 목록에서 같은 쿠폰 템플릿을 한 번씩만 보여주고 싶다고 생각해보자.
function getUniqueCouponTemplates(coupons) {
const templateIds = new Set();
const uniqueTemplates = [];
for (const coupon of coupons) {
if (templateIds.has(coupon.templateId)) {
continue;
}
templateIds.add(coupon.templateId);
uniqueTemplates.push(coupon);
}
return uniqueTemplates;
}
이 코드는 이미 확인한 templateId를 저장하고, 같은 템플릿이 다시 등장하면 결과에 추가하지 않는다.
앞의 중복 요청 검사와 반환 형태는 다르다.
true를 반환한다.그러나 두 코드가 사용하는 해결 원리는 같다.
이미 확인한 정보를 저장하고, 이후의 판단에서 그 정보를 다시 사용한다.
이번에는 한 번 사용된 쿠폰이 목록에 다시 등장하지 않게 해야 한다고 생각해보자.
function excludeRedeemedCoupons(coupons, redeemedCouponIds) {
const redeemedIdSet = new Set(redeemedCouponIds);
return coupons.filter(
(coupon) => !redeemedIdSet.has(coupon.id)
);
}
코드의 모양은 다시 달라졌다. 반복문 대신 filter를 사용하고, 이미 사용된 쿠폰 ID를 먼저 Set으로 변환한다.
하지만 해결 원리는 여전히 같다.
매번 사용 기록 전체를 다시 탐색하지 않도록, 반복해서 조회할 정보를 적절한 구조로 준비한다.
알고리즘을 유형의 이름으로만 외우면 세 문제를 서로 다른 코드로 기억하게 된다.
해결 원리를 중심으로 이해하면 세 문제를 다음과 같이 연결할 수 있다.
| 서비스 문제 | 기억해야 하는 정보 | 다시 사용하는 원리 |
|---|---|---|
| 중복 요청 발견 | 이미 확인한 요청 ID | 이전 등장 여부 확인 |
| 쿠폰 템플릿 중복 제거 | 이미 추가한 템플릿 ID | 같은 결과의 재추가 방지 |
| 사용된 쿠폰 제외 | 이미 사용된 쿠폰 ID | 반복적인 목록 탐색 제거 |
서로 다른 요구사항에서 같은 원리를 발견하는 것이 알고리즘 학습의 중요한 목적이다.
Set을 사용한 코드를 외우는 것과 알고리즘이 올바른 이유를 이해하는 것은 다르다.
다음 반복문을 다시 살펴보자.
for (const request of requests) {
if (processedRequestIds.has(request.requestId)) {
return true;
}
processedRequestIds.add(request.requestId);
}
이 반복문이 현재 요청을 확인하기 직전에는 다음 조건이 항상 유지된다.
processedRequestIds에는 현재 요청보다 앞에서 확인한 모든 요청 ID가 들어 있다.
따라서 현재 requestId가 Set에 존재한다면 같은 ID가 앞에서 한 번 이상 등장했다는 뜻이다.
존재하지 않는다면 현재 ID를 추가한다. 그러면 다음 요청을 확인할 때도 같은 조건이 유지된다.
이처럼 알고리즘이 실행되는 동안 계속 유지되어야 하는 조건을 불변 조건이라고 한다.
이름은 조금 어렵지만 의미는 단순하다.
각 단계를 처리한 뒤에도 올바르게 유지되어야 하는 약속이다.
풀이 코드를 이해했다면 다음 질문에 답할 수 있어야 한다.
Set에는 정확히 어떤 데이터가 들어 있는가?이 질문에 답할 수 없다면 코드를 재현할 수 있어도 해결 원리를 충분히 이해한 것은 아니다.
Set을 사용한 중복 탐지는 하나의 함수 실행 안에서는 잘 동작한다.
const processedRequestIds = new Set();
하지만 이 데이터는 함수가 종료되면 사라진다. 서버가 여러 대라면 각 서버가 서로 다른 Set을 갖는다. 서버가 재시작되어도 이전 기록은 사라진다.
따라서 다음과 같은 문제에는 지역 변수로 만든 Set만 사용할 수 없다.
크리스가 쿠폰 사용 버튼을 두 번 눌렀을 때 같은 요청을 한 번만 처리해야 한다.
첫 번째 요청이 서버 A에서 처리되고 두 번째 요청이 서버 B로 전달될 수 있기 때문이다.
이 경우에도 해결 원리는 같다.
이미 처리한 요청의 식별자를 기억하고, 같은 식별자가 다시 들어오면 중복 처리를 막는다.
다만 정보를 기억해야 하는 범위와 기간이 달라진다.
| 확인 범위 | 정보를 저장할 수 있는 위치 |
|---|---|
| 한 함수의 입력 목록 | 지역 Set |
| 한 화면이 열려 있는 동안 | UI State |
| 한 서버 프로세스 안의 짧은 시간 | 서버 메모리 또는 캐시 |
| 여러 서버가 공유해야 하는 요청 | 공유 캐시 또는 데이터베이스 |
| 재시작 후에도 유지해야 하는 기록 | 데이터베이스 |
여러 서버에서 쿠폰 사용 요청의 중복 처리를 막아야 한다면 데이터베이스에 요청 ID를 저장할 수 있다.
CREATE UNIQUE INDEX unique_coupon_request
ON coupon_redemptions(request_id);
같은 request_id를 두 번 저장할 수 없도록 데이터베이스가 제한한다.
이제 중복 여부에 대한 Source of Truth는 한 서버의 메모리가 아니라 데이터베이스가 된다.
Set 코드 자체는 사용하지 않았지만 해결 원리는 그대로 이어진다.
알고리즘의 원리를 이해하면 구현 환경이 바뀌어도 적절한 도구를 다시 선택할 수 있다.
클라이언트가 전달한 requestId를 중복 방지에 사용한다고 생각해보자.
const requestId = request.body.requestId;
외부에서 값이 들어왔다고 해서 유효한 요청 식별자가 된 것은 아니다.
따라서 서비스에서 사용할 형태로 먼저 검증해야 한다.
const requestId = String(request.body.requestId ?? "").trim();
if (requestId.length < 10 || requestId.length > 100) {
throw new Error("유효하지 않은 요청 ID다.");
}
형식을 검증한 뒤에는 요청 ID가 현재 사용자와 쿠폰에 어떤 범위로 적용되는지도 결정해야 한다.
const idempotencyKey =
`${currentUser.id}:${coupon.id}:${requestId}`;
같은 문자열이라도 사용자와 쿠폰이 다르면 별개의 요청으로 볼 수 있다. 반대로 동일한 사용자와 쿠폰에 같은 요청 ID가 전달되면 중복으로 판단할 수 있다.
알고리즘 문제에서는 입력이 이미 문제의 조건을 만족한다고 가정하는 경우가 많다. 실제 서비스에서는 입력의 신뢰 범위와 식별자의 의미까지 설계해야 한다.
알고리즘을 공부한 뒤 다음과 같이 기록하는 경우가 있다.
중복 찾기
→ Set 사용
→ has로 확인하고 add
이 기록은 코드를 다시 작성하는 데는 도움이 된다. 하지만 문제가 바뀌었을 때 해결 방법을 찾기에는 정보가 부족하다.
조금 다르게 정리할 수 있다.
| 정리할 내용 | 중복 요청 문제에서의 답 |
|---|---|
| 가장 단순한 방법 | 모든 요청을 서로 비교한다 |
| 단순한 방법의 비용 | 같은 요청 목록을 반복해서 탐색한다 |
| 제거해야 할 반복 | 이전 요청을 매번 다시 확인하는 작업 |
| 기억해야 할 정보 | 지금까지 확인한 요청 ID |
| 선택한 구조 | 빠른 존재 여부 확인이 가능한 Set |
| 유지해야 할 조건 | 현재까지 처리한 모든 ID가 저장되어 있다 |
| 적용 범위 | 한 번의 함수 실행 또는 입력 목록 |
| 서비스 확장 시 변화 | 공유 저장소와 고유 제약이 필요하다 |
이렇게 정리하면 Set의 사용법뿐 아니라 그 선택에 이르는 사고 과정이 남는다.
나중에 중복 결제, 중복 회원 가입, 중복 이벤트 처리와 같은 문제를 만났을 때도 같은 원리를 떠올릴 수 있다.
처음부터 가장 효율적인 풀이만 외우면 왜 그 방법이 필요한지 이해하기 어렵다.
먼저 느리더라도 확실하게 동작하는 방법을 생각하는 것이 좋다.
중복 요청 문제에서는 모든 요청을 서로 비교하는 방법이 기준점이 된다.
for (let current = 0; current < requests.length; current++) {
for (let next = current + 1; next < requests.length; next++) {
if (requests[current].requestId === requests[next].requestId) {
return true;
}
}
}
그다음 이 코드가 하는 일을 관찰한다.
requestId의 등장 여부다.이제 개선 방향을 찾을 수 있다.
이전에 확인한 ID를 저장하면 반복 탐색을 제거할 수 있다.
이 과정을 거치면 Set은 외워야 할 정답이 아니라 반복 작업을 없애기 위해 자연스럽게 선택한 자료구조가 된다.
가장 단순한 방법은 실패한 풀이가 아니다. 개선된 알고리즘이 무엇을 줄였고 무엇을 추가했는지 이해하기 위한 기준점이다.
AI에게 중복 탐지 코드를 요청하면 중첩 반복문, Set, 정렬 등 여러 방법을 빠르게 받을 수 있다.
하지만 생성된 코드를 그대로 복사하면 다음 판단은 여전히 남는다.
이 질문에 답할 수 있어야 AI가 만든 풀이를 실제 서비스 조건에 맞게 수정할 수 있다.
AI를 사용할 때도 단순히 정답 코드만 요청하기보다 해결 원리를 함께 질문하는 것이 좋다.
가장 단순한 방법부터 제시하고, 어떤 반복 작업이 발생하는지 설명해줘. 그 반복을 제거하기 위해 어떤 정보를 저장해야 하는지와 선택한 자료구조의 이유도 알려줘. 마지막으로 이 방법이 여러 서버에서 동작할 때 어떤 한계가 있는지 설명해줘.
이런 질문은 더 긴 답을 얻기 위한 것이 아니다.
코드에서 재사용할 수 있는 해결 원리를 분리해내기 위한 질문이다.
알고리즘 문제를 푼 뒤 정답 코드를 저장하기 전에 다음 질문을 확인할 수 있다.
마지막 질문에 대한 답은 다음과 같은 형태가 될 수 있다.
이전에 확인한 요청 ID를 저장해두고 현재 ID의 존재 여부를 확인함으로써, 같은 목록을 반복해서 탐색하지 않고 중복 요청을 발견한다.
이 문장을 이해하고 있다면 코드의 형태가 바뀌어도 같은 원리를 다시 사용할 수 있다.
풀이 코드는 프로그래밍 언어와 구현 환경에 따라 달라진다.
JavaScript에서는 Set을 사용할 수 있고, 데이터베이스에서는 고유 인덱스를 사용할 수 있다. 분산된 서비스에서는 공유 저장소나 멱등성 키가 필요할 수 있다.
그러나 그 아래에 있는 해결 원리는 이어진다.
이 원리를 이해하면 익숙한 문제의 코드를 재현하는 데서 끝나지 않는다. 처음 보는 문제에서도 이미 배운 사고방식을 꺼내 사용할 수 있다.
알고리즘을 공부한다는 것은 풀이 코드를 외우는 일이 아니라, 문제 속에서 반복되는 작업과 필요한 정보를 발견하고 그 해결 원리를 새로운 상황으로 옮기는 연습이다.
많은 문제를 풀었다는 사실보다 중요한 것은 서로 다른 문제 사이의 공통된 구조를 발견했는가이다.
정답 코드를 기억하면 같은 문제를 다시 풀 수 있다.
해결 원리를 이해하면 아직 만나지 않은 문제도 풀기 시작할 수 있다.