
프로그래밍을 처음 배울 때는 문제가 이미 정리된 상태로 주어진다.
예를 들어 다음과 같은 요구사항을 받을 수 있다.
쿠폰을 사용할 수 있는지 확인하는 함수를 작성하라.
function canRedeemCoupon(coupon, now) {
return (
coupon.status === "ACTIVE" &&
coupon.remainingUses > 0 &&
now < coupon.expiresAt
);
}
쿠폰의 상태, 남은 사용 횟수와 만료 시각을 확인해 사용 가능 여부를 반환한다.
입력과 출력이 명확한 문제를 해결하는 연습으로는 충분하다. 개발자는 주어진 조건을 코드로 옮기는 데 집중할 수 있다.
하지만 실제 서비스에서 개발자가 처음 마주하는 것은 정리된 문제가 아니라 사용자가 경험한 현상인 경우가 많다.
쿠폰 사용 버튼을 눌렀는데 사용할 수 없어요.
이 문장을 듣고 바로 canRedeemCoupon 함수를 수정해야 할까?
아직은 무엇을 수정해야 하는지 알 수 없다.
코드를 작성하기 전에 먼저 사용자가 말한 현상을 확인 가능한 문제로 바꿔야 한다.
실제 서비스의 문제 해결은 코드를 작성하면서 시작되는 것이 아니라, 기대한 결과와 실제 결과의 차이를 관찰하고 그 차이가 발생한 조건과 원인을 정의하는 데서 시작된다.
크리스가 쿠폰 서비스의 고객 지원 채널에 다음과 같은 문의를 남겼다고 생각해보자.
Dinner Date 쿠폰을 사용하려고 했는데 계속 실패해요. 다시 사용할 수 있게 해주세요.
이 문의에는 중요한 정보가 들어 있다.
Dinner Date 쿠폰이다.하지만 이것만으로는 실패 원인을 알 수 없다.
크리스가 제안한 “다시 사용할 수 있게 해주세요”를 그대로 구현하면 쿠폰 상태를 ACTIVE로 되돌릴 수도 있다.
await couponRepository.updateStatus(
couponId,
"ACTIVE"
);
코드는 짧고 요청한 결과도 즉시 만들어낼 수 있다.
그러나 쿠폰이 실제로 이미 사용된 상태였다면 다시 활성화해서는 안 된다. 결제나 서비스 제공은 완료되었는데 화면 응답만 실패했을 수도 있다.
사용자가 말한 해결 방법을 바로 구현하기 전에 다음을 구분해야 한다.
| 구분 | 쿠폰 문의에서의 예 |
|---|---|
| 현상 | 쿠폰 사용 화면에서 실패 메시지를 보았다 |
| 기대 | 유효한 쿠폰을 한 번 사용할 수 있어야 한다 |
| 실제 결과 | 사용 성공 여부를 확인할 수 없다 |
| 원인 | 아직 조사되지 않았다 |
| 해결책 | 원인을 확인한 뒤 결정해야 한다 |
사용자의 경험은 문제를 발견하는 중요한 출발점이다. 하지만 사용자가 제안한 기능이 반드시 올바른 해결책인 것은 아니다.
“쿠폰 사용이 안 된다”는 표현은 범위가 넓다.
이를 조금 더 구체적으로 바꿔보자.
활성 상태이고 사용 횟수가 1회 남은 쿠폰을 수신자인 크리스가 사용했을 때, 서버에서는 사용 처리가 완료되었지만 화면에는 실패 메시지가 표시된다.
이제 기대한 결과와 실제 결과를 구분할 수 있다.
이 상황에서 쿠폰을 다시 활성화하는 것은 올바른 해결이 아니다. 서버에서는 이미 정상적으로 사용되었기 때문이다.
진짜 문제는 다음과 같이 정의할 수 있다.
쿠폰 사용은 성공했지만 클라이언트가 성공 응답을 받지 못하면, 사용자가 최종 처리 상태를 확인할 수 없다.
문제를 이렇게 정의하면 해결 방향도 달라진다.
좋은 문제 정의는 코드를 바로 알려주지 않는다. 대신 잘못된 코드를 작성하지 않도록 해결해야 할 범위를 분명하게 만든다.
사용자가 실패 화면을 보았으므로 프런트엔드의 오류 메시지를 수정할 수 있다.
catch {
showMessage("잠시 후 다시 시도해주세요.");
}
이 코드는 사용자에게 다음 행동을 알려준다.
그러나 쿠폰 사용이 이미 처리된 상황에서 다시 시도하면 어떤 일이 생길까?
서버가 중복 요청을 구분하지 못한다면 사용 기록이 두 번 생성될 수 있다. 여러 번 사용할 수 있는 쿠폰이라면 남은 횟수가 추가로 줄어들 수도 있다.
화면의 메시지를 개선하는 일은 필요하지만 데이터의 정확성 문제까지 해결하지는 않는다.
반대로 모든 실패를 서버 문제라고 생각하고 백엔드 코드만 수정해도 안 된다. 서버가 COUPON_ALREADY_REDEEMED라는 정확한 결과를 반환했지만 프런트엔드가 모든 오류를 같은 메시지로 바꾸고 있을 수도 있다.
if (!response.ok) {
throw new Error("쿠폰 사용에 실패했다.");
}
이 코드는 서로 다른 실패 의미를 하나로 합친다.
실패 결과를 구분하면 화면도 적절하게 반응할 수 있다.
const result = await redeemCoupon(couponId);
if (result.reason === "ALREADY_REDEEMED") {
showMessage("이미 사용된 쿠폰이다.");
}
if (result.reason === "EXPIRED") {
showMessage("만료된 쿠폰이다.");
}
if (result.reason === "TEMPORARY_FAILURE") {
showMessage("처리 상태를 확인하고 있다.");
}
문제의 원인이 있는 계층과 사용자가 현상을 경험한 계층은 서로 다를 수 있다.
보이는 화면부터 고치는 것이 아니라 데이터가 이동한 전체 경로를 확인해야 한다.
쿠폰 사용 버튼을 누른 뒤 결과가 화면에 표시되기까지 여러 단계가 존재한다.
flowchart TD
A["사용 버튼 클릭"]
--> B["클라이언트 요청 생성"]
--> C["API 인증·입력 검증"]
--> D["쿠폰 상태 조회"]
--> E["사용 가능 여부 판단"]
--> F["상태와 사용 기록 변경"]
--> G["HTTP 응답"]
--> H["화면 상태 갱신"]
어느 단계에서든 문제가 발생할 수 있다.
| 단계 | 확인할 내용 |
|---|---|
| 버튼 클릭 | 이벤트가 실제로 실행되었는가 |
| 요청 생성 | 올바른 쿠폰 ID와 요청 ID를 보냈는가 |
| 인증·검증 | 현재 사용자가 수신자로 확인되는가 |
| 상태 조회 | 최신 쿠폰 데이터를 읽었는가 |
| 규칙 판단 | 상태, 만료와 사용 횟수를 올바르게 확인했는가 |
| 데이터 변경 | 쿠폰과 사용 기록이 함께 변경되었는가 |
| 응답 | 성공 결과가 클라이언트까지 전달되었는가 |
| 화면 갱신 | 서버 결과를 화면 상태에 반영했는가 |
이 흐름을 따라가면 “쿠폰 사용이 안 된다”는 넓은 현상을 어느 단계의 문제인지 좁힐 수 있다.
알고리즘적 사고는 해결 코드를 빨리 떠올리는 데만 사용되지 않는다. 복잡한 흐름을 확인 가능한 단계로 나누고, 어느 단계까지 올바르게 처리되었는지 추적하는 데도 사용된다.
문제를 조사할 때는 한 번 발생한 현상을 재현 가능한 조건으로 바꿔야 한다.
예를 들어 다음 정보가 필요하다.
다음과 같은 로그가 있다면 처리 흐름을 연결할 수 있다.
logger.info("Coupon redemption completed", {
requestId,
couponId,
userId: currentUser.id,
result: "SUCCESS",
});
로그의 목적은 많은 값을 출력하는 것이 아니다. 하나의 사용자 행동이 시스템을 통과한 경로를 다시 구성할 수 있게 하는 것이다.
단, 쿠폰의 개인 메시지나 사용자의 이름과 같은 정보까지 무조건 기록해서는 안 된다. 문제를 추적하는 데 필요한 식별자와 상태만 남겨야 한다.
재현 조건을 찾지 않고 코드를 먼저 수정하면 우연히 증상이 사라질 수는 있다. 하지만 무엇이 해결되었는지 확인하기 어렵고 같은 문제가 다시 발생할 수 있다.
크리스의 화면에는 쿠폰이 ACTIVE로 표시되어 있을 수 있다.
const coupon = {
id: "coupon_123",
status: "ACTIVE",
remainingUses: 1,
};
하지만 이 데이터는 화면을 열었을 때 받아온 이전 상태일 수 있다.
그 사이 다른 기기에서 쿠폰을 사용했다면 데이터베이스의 최신 상태는 다음과 같을 수 있다.
const storedCoupon = {
id: "coupon_123",
status: "REDEEMED",
remainingUses: 0,
};
화면에 보이는 값과 서버에 저장된 값이 다르다고 해서 둘 중 하나가 무조건 잘못된 것은 아니다. 서로 다른 시점의 상태를 표현하고 있기 때문이다.
다만 최종 사용 가능 여부를 판단할 때는 데이터베이스의 최신 상태를 Source of Truth로 사용해야 한다.
const latestCoupon =
await couponRepository.findById(couponId);
const decision = evaluateRedemption({
coupon: latestCoupon,
requesterId: currentUser.id,
serverNow: clock.now(),
});
프런트엔드 상태는 사용자에게 빠르게 정보를 보여주기 위한 복사본이다. 서버의 판단은 최신 원본 데이터를 기준으로 다시 수행해야 한다.
문제를 정의할 때도 “화면에서 활성으로 보였다”와 “사용 시점에 실제로 활성이었다”를 구분해야 한다.
클라이언트는 다음과 같은 요청을 보낼 수 있다.
const requestBody = {
couponId: "coupon_123",
userId: "user_chris",
requestedAt: "2026-09-07T10:00:00Z",
};
하지만 userId와 requestedAt을 그대로 신뢰해서는 안 된다.
사용자는 요청 내용을 변경할 수 있고 기기의 시간도 정확하지 않을 수 있다.
서버에서는 신뢰할 수 있는 출처를 기준으로 명령을 구성해야 한다.
const command = {
couponId: validateCouponId(
request.body.couponId
),
requesterId: session.user.id,
requestedAt: clock.now(),
requestId: validateRequestId(
request.headers["idempotency-key"]
),
};
쿠폰 ID와 요청 ID는 외부 입력이므로 형식을 검증한다. 사용자 ID는 인증된 세션에서 가져오고, 판단 시각은 서버의 시계를 사용한다.
문제 조사에서도 데이터 출처를 구분해야 한다.
클라이언트가 보낸 값만 확인하면 서버가 실제로 사용한 사용자와 시각을 잘못 추측할 수 있다. 반대로 서버 로그만 보면 사용자가 화면에서 어떤 상태를 보고 있었는지 놓칠 수 있다.
진짜 문제를 정의하려면 각 데이터가 어디에서 왔고 어느 시점을 나타내는지 확인해야 한다.
문제를 처음 발견했을 때는 원인을 바로 확정하기 어렵다.
이때 다음과 같은 가설을 세울 수 있다.
가설은 확인되지 않은 설명이다. 해결책과 구분해야 한다.
각 가설에는 확인 방법이 필요하다.
| 가설 | 확인할 증거 |
|---|---|
| 버튼이 두 번 실행됨 | 같은 화면에서 생성된 요청 수 |
| 응답만 끊김 | 서버 성공 로그와 클라이언트 네트워크 기록 |
| 오래된 상태 사용 | 화면 조회 시각과 사용 요청 시각 |
| 시간 기준이 다름 | 클라이언트와 서버가 비교한 시각 |
| 중복 처리됨 | 같은 요청 ID의 사용 기록 수 |
증거로 지지되지 않는 가설은 버린다. 가장 가능성이 높아 보이는 코드를 무작정 수정하지 않는다.
문제 해결은 처음 떠오른 설명을 구현하는 과정이 아니라, 가능한 원인을 좁혀가며 확인된 원인에 맞는 해결책을 선택하는 과정이다.
조사 결과 다음 사실을 확인했다고 생각해보자.
이번에 해결할 문제의 범위는 다음과 같이 정할 수 있다.
문제의 경계를 정하지 않으면 작은 오류를 해결하면서 서비스 전체를 수정하려 할 수 있다. 변경 범위가 커질수록 새로운 오류가 생길 가능성도 커진다.
좋은 문제 정의는 해야 할 일뿐 아니라 이번에는 하지 않을 일도 분명하게 만든다.
문제를 다음과 같이 정의했다고 생각해보자.
쿠폰 사용이 서버에서 완료된 뒤 응답 전달에 실패하더라도, 같은 요청을 다시 보내면 중복 사용 없이 이전 성공 결과를 확인할 수 있어야 한다.
이제 검증 가능한 성공 조건을 만들 수 있다.
이 조건이 있어야 어떤 코드를 작성하고 어떤 테스트를 만들어야 하는지 결정할 수 있다.
it("응답 유실 후 재요청해도 한 번만 사용된다", async () => {
await redeemCoupon(command);
const retried = await redeemCoupon(command);
expect(retried.status).toBe("REDEEMED");
expect(
await countRedemptions(command.requestId)
).toBe(1);
});
이 테스트는 특정 함수의 내부 구현을 확인하지 않는다.
응답이 유실되고 요청이 다시 전달되어도 쿠폰 사용이 한 번만 반영된다는 서비스의 약속을 검증한다.
성공 조건을 먼저 정하면 코드는 그 조건을 달성하기 위한 수단이 된다.
진짜 문제가 분명해지면 여러 해결 방법을 비교할 수 있다.
| 해결 방법 | 해결하는 부분 | 남는 한계 |
|---|---|---|
| 버튼을 한 번 누른 뒤 비활성화 | 같은 화면의 연속 클릭 감소 | 네트워크 재시도와 다른 기기를 막지 못함 |
| 실패 후 쿠폰 다시 조회 | 최신 상태를 화면에 반영 | 중복 처리를 직접 방지하지 못함 |
| 요청 ID로 처리 결과 저장 | 같은 요청의 중복 처리 방지 | 저장 기간과 키 범위를 결정해야 함 |
| 데이터베이스 고유 제약 | 중복 사용 기록 생성 방지 | 어떤 값을 중복 기준으로 삼을지 필요 |
| 트랜잭션으로 상태 변경 묶기 | 쿠폰과 사용 기록의 일관성 유지 | 외부 시스템 호출은 별도 처리 필요 |
각 방법은 해결하는 문제가 다르다.
하나의 기술을 사용했다고 전체 문제가 자동으로 해결되지 않는다. 정의한 성공 조건과 제약을 기준으로 필요한 방법을 조합해야 한다.
코드를 먼저 작성하면 현재 구현에 맞춰 문제를 설명하게 되기 쉽다. 문제를 먼저 정의하면 여러 구현을 비교하고 더 단순한 방법을 선택할 수 있다.
서비스에서 오류나 새로운 요구사항을 발견했을 때 다음 질문부터 확인할 수 있다.
마지막 질문에 답하기 어렵다면 구현을 시작하기 전에 문제를 더 조사할 필요가 있다.
문제를 정의하는 과정은 개발을 늦추는 준비 작업처럼 보일 수 있다.
하지만 문제를 충분히 확인하지 않고 코드를 작성하면 다음과 같은 일이 발생한다.
반대로 문제를 먼저 정의하면 구현의 방향이 선명해진다.
문제 해결은 코드를 작성하면서 시작되는 것이 아니다. 해결해야 할 차이를 관찰하고, 원인을 확인하며, 성공 조건과 범위를 정의한 뒤에 코드가 시작된다.
개발자가 작성하는 코드는 문제 해결 과정의 중요한 일부다.
그러나 문제를 잘못 정의하면 아무리 정확하게 작성한 코드도 엉뚱한 결과를 만든다. 반대로 문제를 명확하게 정의하면 구현해야 할 코드가 줄어들고 검증 기준도 구체적으로 정해진다.
코드를 빨리 작성하는 것보다 먼저 해야 할 일은 무엇을 해결하고 있는지 분명하게 말할 수 있는 상태를 만드는 것이다.