문제 해결은 코드를 작성하면서 시작되는 것이 아니다

vx_developer·2026년 9월 6일

코테보다가

목록 보기
5/25
post-thumbnail

프로그래밍을 처음 배울 때는 문제가 이미 정리된 상태로 주어진다.

예를 들어 다음과 같은 요구사항을 받을 수 있다.

쿠폰을 사용할 수 있는지 확인하는 함수를 작성하라.

function canRedeemCoupon(coupon, now) {
  return (
    coupon.status === "ACTIVE" &&
    coupon.remainingUses > 0 &&
    now < coupon.expiresAt
  );
}

쿠폰의 상태, 남은 사용 횟수와 만료 시각을 확인해 사용 가능 여부를 반환한다.

입력과 출력이 명확한 문제를 해결하는 연습으로는 충분하다. 개발자는 주어진 조건을 코드로 옮기는 데 집중할 수 있다.

하지만 실제 서비스에서 개발자가 처음 마주하는 것은 정리된 문제가 아니라 사용자가 경험한 현상인 경우가 많다.

쿠폰 사용 버튼을 눌렀는데 사용할 수 없어요.

이 문장을 듣고 바로 canRedeemCoupon 함수를 수정해야 할까?

아직은 무엇을 수정해야 하는지 알 수 없다.

  • 버튼 자체가 동작하지 않는 것인가?
  • API 요청이 전송되지 않은 것인가?
  • 요청은 전송되었지만 서버가 거절한 것인가?
  • 쿠폰이 이미 사용된 상태인가?
  • 화면에 오래된 쿠폰 정보가 남아 있는 것인가?
  • 응답은 성공했지만 UI가 갱신되지 않은 것인가?
  • 네트워크가 느려 사용자가 실패했다고 생각한 것인가?
  • 서버에서는 성공했지만 응답 전달만 실패한 것인가?

코드를 작성하기 전에 먼저 사용자가 말한 현상을 확인 가능한 문제로 바꿔야 한다.

실제 서비스의 문제 해결은 코드를 작성하면서 시작되는 것이 아니라, 기대한 결과와 실제 결과의 차이를 관찰하고 그 차이가 발생한 조건과 원인을 정의하는 데서 시작된다.

사용자가 알려주는 것은 문제의 이름이 아니라 경험한 현상이다

크리스가 쿠폰 서비스의 고객 지원 채널에 다음과 같은 문의를 남겼다고 생각해보자.

Dinner Date 쿠폰을 사용하려고 했는데 계속 실패해요. 다시 사용할 수 있게 해주세요.

이 문의에는 중요한 정보가 들어 있다.

  • 사용자는 크리스다.
  • 대상은 Dinner Date 쿠폰이다.
  • 쿠폰 사용 과정에서 실패를 경험했다.
  • 사용자는 쿠폰을 사용할 수 있어야 한다고 생각한다.

하지만 이것만으로는 실패 원인을 알 수 없다.

크리스가 제안한 “다시 사용할 수 있게 해주세요”를 그대로 구현하면 쿠폰 상태를 ACTIVE로 되돌릴 수도 있다.

await couponRepository.updateStatus(
  couponId,
  "ACTIVE"
);

코드는 짧고 요청한 결과도 즉시 만들어낼 수 있다.

그러나 쿠폰이 실제로 이미 사용된 상태였다면 다시 활성화해서는 안 된다. 결제나 서비스 제공은 완료되었는데 화면 응답만 실패했을 수도 있다.

사용자가 말한 해결 방법을 바로 구현하기 전에 다음을 구분해야 한다.

구분쿠폰 문의에서의 예
현상쿠폰 사용 화면에서 실패 메시지를 보았다
기대유효한 쿠폰을 한 번 사용할 수 있어야 한다
실제 결과사용 성공 여부를 확인할 수 없다
원인아직 조사되지 않았다
해결책원인을 확인한 뒤 결정해야 한다

사용자의 경험은 문제를 발견하는 중요한 출발점이다. 하지만 사용자가 제안한 기능이 반드시 올바른 해결책인 것은 아니다.

문제는 기대한 상태와 실제 상태의 차이로 정의할 수 있다

“쿠폰 사용이 안 된다”는 표현은 범위가 넓다.

이를 조금 더 구체적으로 바꿔보자.

활성 상태이고 사용 횟수가 1회 남은 쿠폰을 수신자인 크리스가 사용했을 때, 서버에서는 사용 처리가 완료되었지만 화면에는 실패 메시지가 표시된다.

이제 기대한 결과와 실제 결과를 구분할 수 있다.

기대한 결과

  • 쿠폰 사용 요청이 한 번 처리된다.
  • 쿠폰의 남은 사용 횟수가 0이 된다.
  • 사용 기록이 생성된다.
  • 화면에 사용 완료 상태가 표시된다.

실제 결과

  • 쿠폰의 남은 사용 횟수는 0이 되었다.
  • 사용 기록도 생성되었다.
  • 네트워크 응답이 중간에 끊겼다.
  • 화면에는 일반적인 실패 메시지가 표시되었다.

이 상황에서 쿠폰을 다시 활성화하는 것은 올바른 해결이 아니다. 서버에서는 이미 정상적으로 사용되었기 때문이다.

진짜 문제는 다음과 같이 정의할 수 있다.

쿠폰 사용은 성공했지만 클라이언트가 성공 응답을 받지 못하면, 사용자가 최종 처리 상태를 확인할 수 없다.

문제를 이렇게 정의하면 해결 방향도 달라진다.

  • 실패 후 최신 쿠폰 상태를 다시 조회한다.
  • 동일한 요청을 다시 보내도 중복 처리되지 않게 한다.
  • 사용 기록을 통해 이전 요청의 결과를 확인한다.
  • 네트워크 오류와 비즈니스 거절을 다른 메시지로 보여준다.

좋은 문제 정의는 코드를 바로 알려주지 않는다. 대신 잘못된 코드를 작성하지 않도록 해결해야 할 범위를 분명하게 만든다.

증상과 원인을 구분하지 않으면 보이는 곳만 수정하게 된다

사용자가 실패 화면을 보았으므로 프런트엔드의 오류 메시지를 수정할 수 있다.

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를 보냈는가
인증·검증현재 사용자가 수신자로 확인되는가
상태 조회최신 쿠폰 데이터를 읽었는가
규칙 판단상태, 만료와 사용 횟수를 올바르게 확인했는가
데이터 변경쿠폰과 사용 기록이 함께 변경되었는가
응답성공 결과가 클라이언트까지 전달되었는가
화면 갱신서버 결과를 화면 상태에 반영했는가

이 흐름을 따라가면 “쿠폰 사용이 안 된다”는 넓은 현상을 어느 단계의 문제인지 좁힐 수 있다.

알고리즘적 사고는 해결 코드를 빨리 떠올리는 데만 사용되지 않는다. 복잡한 흐름을 확인 가능한 단계로 나누고, 어느 단계까지 올바르게 처리되었는지 추적하는 데도 사용된다.

추측보다 재현 가능한 조건을 먼저 찾아야 한다

문제를 조사할 때는 한 번 발생한 현상을 재현 가능한 조건으로 바꿔야 한다.

예를 들어 다음 정보가 필요하다.

  • 어떤 사용자가 요청했는가
  • 어떤 쿠폰을 사용했는가
  • 요청한 시각은 언제인가
  • 쿠폰은 어떤 상태였는가
  • 어떤 기기와 앱 버전을 사용했는가
  • 어떤 요청 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는 인증된 세션에서 가져오고, 판단 시각은 서버의 시계를 사용한다.

문제 조사에서도 데이터 출처를 구분해야 한다.

클라이언트가 보낸 값만 확인하면 서버가 실제로 사용한 사용자와 시각을 잘못 추측할 수 있다. 반대로 서버 로그만 보면 사용자가 화면에서 어떤 상태를 보고 있었는지 놓칠 수 있다.

진짜 문제를 정의하려면 각 데이터가 어디에서 왔고 어느 시점을 나타내는지 확인해야 한다.

원인을 모를 때는 해결책이 아니라 가설을 세워야 한다

문제를 처음 발견했을 때는 원인을 바로 확정하기 어렵다.

이때 다음과 같은 가설을 세울 수 있다.

  1. 버튼 이벤트가 두 번 실행되었다.
  2. API 요청은 성공했지만 응답이 끊겼다.
  3. 화면이 오래된 쿠폰 상태를 사용했다.
  4. 서버와 클라이언트의 만료 시각 기준이 다르다.
  5. 동일한 요청이 중복 처리되었다.

가설은 확인되지 않은 설명이다. 해결책과 구분해야 한다.

각 가설에는 확인 방법이 필요하다.

가설확인할 증거
버튼이 두 번 실행됨같은 화면에서 생성된 요청 수
응답만 끊김서버 성공 로그와 클라이언트 네트워크 기록
오래된 상태 사용화면 조회 시각과 사용 요청 시각
시간 기준이 다름클라이언트와 서버가 비교한 시각
중복 처리됨같은 요청 ID의 사용 기록 수

증거로 지지되지 않는 가설은 버린다. 가장 가능성이 높아 보이는 코드를 무작정 수정하지 않는다.

문제 해결은 처음 떠오른 설명을 구현하는 과정이 아니라, 가능한 원인을 좁혀가며 확인된 원인에 맞는 해결책을 선택하는 과정이다.

문제의 경계를 정해야 불필요한 해결을 피할 수 있다

조사 결과 다음 사실을 확인했다고 생각해보자.

  • 쿠폰 사용은 서버에서 정상적으로 완료되었다.
  • 모바일 네트워크가 끊겨 응답만 전달되지 않았다.
  • 사용자가 다시 시도하면 서버는 이미 사용된 쿠폰이라고 응답한다.
  • 화면은 이를 일반적인 실패로 표시한다.

이번에 해결할 문제의 범위는 다음과 같이 정할 수 있다.

포함하는 범위

  • 요청마다 고유한 식별자를 사용한다.
  • 같은 요청이 다시 오면 이전 처리 결과를 반환한다.
  • 네트워크 오류 후 최신 쿠폰 상태를 조회한다.
  • 이미 완료된 요청을 성공 상태로 복구해 보여준다.
  • 비즈니스 거절과 일시적 통신 실패를 구분한다.

포함하지 않는 범위

  • 모든 쿠폰 정책을 다시 설계하는 일
  • 전체 알림 시스템을 변경하는 일
  • 관계없는 쿠폰 목록 화면을 개편하는 일
  • 모든 네트워크 실패를 제거하는 일

문제의 경계를 정하지 않으면 작은 오류를 해결하면서 서비스 전체를 수정하려 할 수 있다. 변경 범위가 커질수록 새로운 오류가 생길 가능성도 커진다.

좋은 문제 정의는 해야 할 일뿐 아니라 이번에는 하지 않을 일도 분명하게 만든다.

구현 전에 성공 조건을 정해야 해결 여부를 판단할 수 있다

문제를 다음과 같이 정의했다고 생각해보자.

쿠폰 사용이 서버에서 완료된 뒤 응답 전달에 실패하더라도, 같은 요청을 다시 보내면 중복 사용 없이 이전 성공 결과를 확인할 수 있어야 한다.

이제 검증 가능한 성공 조건을 만들 수 있다.

  • 동일한 요청 ID는 쿠폰 사용 횟수를 한 번만 줄인다.
  • 첫 응답이 유실되어도 재요청에서 이전 성공 결과를 반환한다.
  • 사용 기록은 한 건만 생성된다.
  • UI는 재확인 후 쿠폰을 사용 완료 상태로 표시한다.
  • 다른 사용자의 요청 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로 처리 결과 저장같은 요청의 중복 처리 방지저장 기간과 키 범위를 결정해야 함
데이터베이스 고유 제약중복 사용 기록 생성 방지어떤 값을 중복 기준으로 삼을지 필요
트랜잭션으로 상태 변경 묶기쿠폰과 사용 기록의 일관성 유지외부 시스템 호출은 별도 처리 필요

각 방법은 해결하는 문제가 다르다.

하나의 기술을 사용했다고 전체 문제가 자동으로 해결되지 않는다. 정의한 성공 조건과 제약을 기준으로 필요한 방법을 조합해야 한다.

코드를 먼저 작성하면 현재 구현에 맞춰 문제를 설명하게 되기 쉽다. 문제를 먼저 정의하면 여러 구현을 비교하고 더 단순한 방법을 선택할 수 있다.

문제를 정의하기 전에 물어봐야 할 질문

서비스에서 오류나 새로운 요구사항을 발견했을 때 다음 질문부터 확인할 수 있다.

  1. 누가 어떤 상황에서 문제를 경험했는가?
  2. 사용자가 기대한 결과는 무엇인가?
  3. 실제로 발생한 결과는 무엇인가?
  4. 현재 알고 있는 것은 사실인가, 추측인가?
  5. 사용자가 말한 것은 현상인가, 원인인가, 해결책인가?
  6. 문제를 재현하는 구체적인 조건은 무엇인가?
  7. 정상적으로 처리된 마지막 단계는 어디인가?
  8. 각 단계에서 확인할 수 있는 로그와 데이터가 있는가?
  9. 화면의 상태와 데이터베이스의 최신 상태가 같은가?
  10. 각 데이터는 어디에서 왔으며 신뢰할 수 있는가?
  11. 가능한 원인 가설은 무엇이며 어떻게 검증할 것인가?
  12. 이 문제가 사용자와 비즈니스에 미치는 영향은 무엇인가?
  13. 이번에 해결해야 하는 범위와 제외할 범위는 무엇인가?
  14. 해결되었다고 판단할 수 있는 관찰 가능한 조건은 무엇인가?
  15. 코드를 작성하지 않고도 문제를 한 문장으로 설명할 수 있는가?

마지막 질문에 답하기 어렵다면 구현을 시작하기 전에 문제를 더 조사할 필요가 있다.

좋은 문제 정의는 코드를 늦추는 것이 아니라 재작업을 줄인다

문제를 정의하는 과정은 개발을 늦추는 준비 작업처럼 보일 수 있다.

하지만 문제를 충분히 확인하지 않고 코드를 작성하면 다음과 같은 일이 발생한다.

  • 현상만 가리고 원인은 남는다.
  • 사용자 요청과 다른 기능을 만든다.
  • 관계없는 코드를 수정한다.
  • 같은 오류가 다른 조건에서 다시 발생한다.
  • 무엇이 개선되었는지 확인할 수 없다.
  • 변경 범위가 계속 커진다.

반대로 문제를 먼저 정의하면 구현의 방향이 선명해진다.

  1. 기대한 결과와 실제 결과를 구분한다.
  2. 데이터 흐름을 따라 실패한 지점을 찾는다.
  3. 사실과 가설을 분리한다.
  4. 원인을 증거로 확인한다.
  5. 해결 범위와 성공 조건을 정한다.
  6. 그다음 적절한 알고리즘과 코드를 선택한다.

문제 해결은 코드를 작성하면서 시작되는 것이 아니다. 해결해야 할 차이를 관찰하고, 원인을 확인하며, 성공 조건과 범위를 정의한 뒤에 코드가 시작된다.

개발자가 작성하는 코드는 문제 해결 과정의 중요한 일부다.

그러나 문제를 잘못 정의하면 아무리 정확하게 작성한 코드도 엉뚱한 결과를 만든다. 반대로 문제를 명확하게 정의하면 구현해야 할 코드가 줄어들고 검증 기준도 구체적으로 정해진다.

코드를 빨리 작성하는 것보다 먼저 해야 할 일은 무엇을 해결하고 있는지 분명하게 말할 수 있는 상태를 만드는 것이다.

profile
Vision eXperience Developer

0개의 댓글