좋은 코드는 예쁜 코드가 아니라, 다음 결정을 쉽게 만드는 코드에 가깝다.
개발을 하다 보면 코드를 잘 짠다는 말을 자주 쓴다. 그런데 그 말은 생각보다 애매하다. 어떤 사람은 짧은 코드를 좋은 코드라고 하고, 어떤 사람은 추상화가 잘 된 코드를 좋은 코드라고 한다. 또 어떤 사람은 테스트가 많은 코드를 좋은 코드라고 한다.
물론 다 맞는 말이다. 하지만 실제 제품을 운영하다 보면 좋은 코드의 기준은 조금 더 현실적인 곳에서 드러난다.
좋은 코드는 다음 사람이 판단할 때 드는 비용을 줄여준다.
제품 코드는 한 번 작성되고 끝나지 않는다. 계속 읽히고, 수정되고, 리뷰되고, 장애 상황에서 다시 해석된다. 그래서 코드의 진짜 품질은 “지금 잘 동작하는가”보다 “나중에 덜 추측해도 되는가”에서 드러난다.
결제 취소 로직을 예로 들어보자
처음에는 단순하다. 주문 상태가 결제 완료면 취소하고, 아니면 막으면 된다.
그런데 시간이 지나면 조건이 늘어난다. 이미 배송 준비 중인 주문은 취소할 수 없고, 쿠폰을 쓴 주문은 환불 금액 계산이 다르다. 부분 취소가 가능한 상품과 불가능한 상품이 나뉘고, PG사 취소는 성공했는데 내부 상태 업데이트가 실패하는 경우도 생긴다.
이때 코드가 한 함수 안에서 모든 일을 처리하고 있으면 처음에는 빠르게 보인다.
주문을 가져온다. 상태를 확인한다. 쿠폰을 확인한다. PG 취소를 호출한다. 재고를 되돌린다. 알림을 보낸다. 실패하면 에러를 던진다.
데모는 잘 된다. 하지만 나중에 누군가 “이 주문은 왜 취소가 안 됐지?”라고 물으면 개발자는 함수 전체를 머릿속으로 다시 실행해야 한다. 어떤 조건에서 막힌 건지, 어떤 외부 호출까지 성공한 건지, 어디까지 되돌려야 하는지 추측해야 한다.
좋은 코드는 여기서 멋진 패턴을 추가하는 게 아니다. 먼저 판단과 실행을 나눈다.
- 이 주문은 취소 가능한가
- 취소 가능하다면 어떤 방식의 취소인가
- 환불 금액은 얼마인가
- 실행 중 실패하면 어디까지 보상해야 하는가
이 판단들이 이름을 가진 함수나 객체로 분리되면 상황이 달라진다. 코드는 조금 길어질 수 있다. 하지만 다음 사람은 전체 흐름을 추측하지 않아도 된다. 어디가 정책이고, 어디가 실행이고, 어디가 외부 시스템 호출인지 보인다.
이름은 장식이 아니라 판단 비용을 줄이는 장치다
result, data, status 같은 이름은 당장은 편하다. 하지만 결제 도메인에서는 위험하다.
status가 주문 상태인지, 결제 상태인지, 배송 상태인지, PG 응답 상태인지 매번 확인해야 하기 때문이다.
반대로 refundEligibility, capturedPayment, cancelableOrder, refundFailureReason 같은 이름은 길지만 판단 비용을 줄인다. 이 값이 무엇을 위한 값인지 코드가 먼저 말해준다.
좋은 이름은 코드를 예쁘게 만드는 장식이 아니다. 다음 사람이 잘못된 가정을 하지 않게 만드는 장치다.
외부 시스템의 모양을 내부 언어로 만들지 않는다
PG사 응답을 서비스 내부 여러 곳에서 그대로 쓰기 시작하면, 그 순간 PG사의 응답 구조가 우리 코드의 내부 언어가 된다.
res.data.resultCode, res.body.payment.status, cancelYn 같은 값이 컨트롤러, 서비스, 배치, 어드민 화면까지 퍼진다.
처음엔 빠르다. 매핑 계층을 만들 필요가 없으니까.
하지만 PG사가 필드명을 바꾸거나, 특정 실패 케이스에서 다른 응답을 주거나, 새 결제수단을 추가하면 수정 범위가 갑자기 넓어진다. 문제는 변경 자체가 어렵다기보다, 어디까지 영향이 있는지 알기 어렵다는 데 있다.
좋은 코드는 외부의 모양을 내부 전체에 퍼뜨리지 않는다. 바깥의 응답을 우리 서비스가 이해하는 언어로 바꿔서 들여온다.
예를 들면 PG 응답은 PaymentProviderResponse로 끝나고, 내부에서는 PaymentCancellationResult나 RefundRequestResult처럼 우리 도메인의 말로 다룬다. 그러면 외부 시스템의 이상함은 경계 안에 갇힌다. 바깥이 흔들려도 안쪽 전체가 같이 흔들리지 않는다.
불가능한 상태를 열어두지 않는다
많은 버그는 값이 없어서 생기기보다, 말이 안 되는 상태가 코드 안에 존재할 수 있어서 생긴다.
예를 들어 주문 객체에 paidAt, canceledAt, refundedAt, shippedAt이 모두 optional로 열려 있다고 해보자. 그러면 이론상 이런 상태가 가능해진다.
- 결제는 안 됐는데 환불 시간이 있다
- 취소됐는데 배송 완료 시간이 있다
- 환불은 됐는데 PG 취소 ID가 없다
물론 개발자는 “그런 상태는 실제로 안 만들면 되지”라고 생각할 수 있다. 하지만 제품이 커질수록 그 믿음은 약해진다. 배치가 있고, 어드민 수정이 있고, 마이그레이션이 있고, 외부 웹훅이 있고, 예외 처리 코드가 있다.
좋은 코드는 불가능한 상태를 나중에 if문으로 계속 방어하지 않는다. 애초에 그런 상태가 쉽게 생기지 않도록 모델을 나눈다.
주문은 단순히 Order 하나가 아니라 PendingOrder, PaidOrder, CanceledOrder, ShippedOrder처럼 다르게 표현될 수 있다. 결제도 PendingPayment, CapturedPayment, FailedPayment, RefundedPayment처럼 나눌 수 있다.
이건 타입을 예쁘게 쓰자는 이야기가 아니다. 비즈니스에서 다른 상태를 코드에서도 다르게 대우하자는 이야기다. 그래야 “이 상태에서 이 행동을 해도 되는가?”라는 질문을 매번 사람 머릿속에서 하지 않아도 된다.
에러는 실패 메시지가 아니라 디버깅의 시작점이다
장애 상황에서 가장 힘든 에러는 실패한 에러가 아니다. 아무것도 말해주지 않는 에러다.
Something went wrong
Internal server error
Failed to process request
이런 메시지는 안전해 보이지만, 실제로는 아무에게도 도움이 되지 않는다. 사용자는 무엇을 다시 입력해야 하는지 모르고, CS는 어떤 안내를 해야 하는지 모르고, 개발자는 로그를 다시 뒤져야 한다.
좋은 에러는 모든 내부 정보를 노출하지 않으면서도, 다음 행동을 가능하게 만든다.
예를 들어 결제 취소 실패라면 단순히 “취소 실패”가 아니라 이런 정보가 필요하다.
- 실패 코드:
PAYMENT_ALREADY_CANCELED - 사용자 메시지: “이미 취소된 결제입니다.”
- 추적 ID:
requestId - 안전한 맥락:
orderId,paymentId,provider,failureReason
중요한 건 메시지와 코드를 구분하는 것이다. 메시지는 사람을 위한 것이고, 코드는 시스템을 위한 것이다. 프론트엔드가 에러 문구를 파싱해서 분기하기 시작하면 이미 위험하다. 문구가 바뀌면 로직이 깨지고, 번역이 바뀌면 상태 처리가 깨진다.
좋은 에러는 다음 사람이 원인을 좁힐 수 있게 해준다. 에러는 실패의 끝이 아니라, 디버깅의 시작점이어야 한다.
PR도 코드 품질의 일부다
많은 개발자는 기능이 동작하면 일이 끝났다고 생각한다. 하지만 팀에서 코드는 혼자 머지되지 않는다. 누군가 리뷰해야 하고, 문제가 생기면 되돌려야 한다.
예를 들어 하나의 PR에 이런 변경이 모두 들어 있다고 해보자.
- 주문 취소 정책 변경
- 결제 취소 로직 리팩터링
- PG 응답 필드명 변경 대응
- 어드민 UI 수정
- 테스트 추가
- 오래된 함수 정리
- 에러 메시지 개선
이 PR은 로컬에서 잘 돌 수 있다. 하지만 리뷰어 입장에서는 위험하다. 어떤 변경이 실제 정책 변경이고, 어떤 변경이 단순 정리인지 구분하기 어렵다. 나중에 문제가 생겨도 무엇을 되돌려야 할지 애매하다.
좋은 변경은 작고, 읽히고, 되돌릴 수 있다.
먼저 이름을 바꾸는 PR. 그다음 정책 판단 함수를 추가하는 PR. 그다음 실제 취소 플로우에 연결하는 PR. 그다음 UI를 바꾸는 PR. 그다음 에러 메시지를 정리하는 PR.
이 방식은 겉으로 보면 느려 보인다. 하지만 리뷰, 디버깅, 롤백까지 포함하면 훨씬 빠르다. 팀이 코드를 믿을 수 있기 때문이다.
복잡도를 어디에 둘 것인가
좋은 코드는 복잡도를 없애는 코드가 아니다. 복잡도는 사라지지 않는다. 제품이 복잡하면 코드는 어느 정도 복잡할 수밖에 없다.
중요한 건 그 복잡도를 어디에 둘 것인가다.
이름에 둘 것인가. 상태 모델에 둘 것인가. 경계 계층에 둘 것인가. 테스트 가능한 판단 함수에 둘 것인가. 추적 가능한 에러에 둘 것인가. 작은 PR의 흐름에 둘 것인가.
아니면 다음 사람의 머릿속에 둘 것인가.
좋지 않은 코드는 복잡도를 숨긴다. 그래서 처음엔 단순해 보인다. 하지만 시간이 지나면 그 숨겨진 복잡도가 추측, 불안, 장애 대응 비용으로 돌아온다.
좋은 코드는 복잡도를 드러낸다. 그래서 처음엔 조금 길고 심심해 보일 수 있다. 하지만 나중에 읽는 사람은 덜 헤맨다. 수정하는 사람은 덜 불안하다. 리뷰하는 사람은 더 정확히 판단할 수 있다.
그래서 코드 품질은 취향이나 미학의 문제가 아니다.
코드 품질은 운영 비용의 문제다. 그리고 좋은 코드는 팀의 다음 판단 비용을 낮추는 코드다.