DRY

  • DRY
  • Don't Repeat Yourself
  • 지식 중복
2026-10-05

한 줄 결론

DRY는 시스템 안에서 하나의 지식이 하나의 표현만 갖게 하라는 원칙이다. 판정 기준은 코드가 닮았는지가 아니라 어떤 사실 하나가 바뀔 때 두 곳을 같이 고쳐야 하는지다. 몸체가 똑같은 두 함수라도 한쪽을 고칠 때 다른 쪽을 고칠 필요가 없으면 중복이 아니며, 한 줄도 닮지 않은 CSS와 JS라도 한쪽을 고칠 때 다른 쪽도 함께 고쳐야 하면 중복이다. 그래서 모양만 보고 DRY 위반이라며 합치자는 말은 판정 대상을 잘못 짚은 말이다.

질문 바로잡기

함수 몸체가 똑같은 코드를 보고 "DRY 위반이니 합치자"고 말하는 리뷰가 있다. DRY가 보는 것은 닮은 코드의 수가 아니라 지식 하나에 딸린 표현의 수라서, 이 리뷰는 판정 대상을 잘못 짚었다. DRY를 만든 저자들도 많은 독자가 이 원칙을 "소스 코드를 복사해 붙여넣지 말라"로 잘못 받아들였다며, 그것은 DRY의 작은 일부일 뿐이라고 썼다. DRY는 지식과 의도의 중복을 다루는 원칙이고, 같은 것을 두 곳에서, 때로는 전혀 다른 모양으로 표현하는 일을 막는다.

그런데도 코드 모양으로 판정하게 되는 데는 이유가 있다. "너 자신을 반복하지 말라"라는 이름만으로는 무엇을 반복하지 말라는 건지 알 수 없어, 개발자들은 눈에 가장 잘 띄는 복사 붙여넣기로 해석한다. 흔히 드는 위반 예시도 대부분 그런 모양이다. 그래서 복붙이 위반의 정의처럼 보이지만, 복붙은 지식 중복이 드러나는 가장 흔한 모양일 뿐이다. 지식이 같은데 모양이 다른 중복은 눈에 안 잡히고, 모양이 같은데 지식이 다른 쌍은 눈에 잡히지만 중복이 아니다.

모양이 똑같은 코드 모양이 전혀 다른 코드 나이 검증 assertMin(value, 0) 수량 검증 assertMin(value, 0) CSS 미디어쿼리 max-width: 768px JS 모바일 판별 innerWidth <= 768 나이 규칙이 바뀌면 모바일 기준 폭이 바뀌면 중복 아님 나이 검증만 고치면 됨 중복 CSS와 JS를 둘 다 고쳐야 함
코드만 보면 왼쪽이 중복 같지만, 사실이 바뀔 때 고칠 곳을 따라가면 판정은 반대로 나온다.

그래서 코드 둘을 놓고 물어야 할 것은 얼마나 닮았는지가 아니라, 둘이 같은 사실 하나를 각자 표현하고 있는지다.

무엇을 뜻하는가: 지식 하나에 표현 하나

DRY의 원문은 "모든 지식은 시스템 안에서 단 하나의, 모호하지 않은, 권위 있는 표현을 가져야 한다"이다. 여기서 지식은 시스템이 담고 있는 사실과 결정이다. 성인 기준이 19세라는 규칙, 모바일 기준 폭이 768픽셀이라는 결정, 주문 응답에 어떤 필드가 있다는 계약이 모두 지식이다. 표현은 그 지식을 담은 코드, 주석, 문서, 스키마, 설정 같은 모든 형태이고, 표현 둘이 지식 하나를 나눠 들고 있는 상태가 중복이다. 저자들이 드는 사례도 코드와 주석, 코드와 문서처럼 형식이 다른 둘의 중복이 많다.

판별은 질문 하나로 한다. 어떤 사실 하나가 바뀔 때 두 곳을 같이 고쳐야 하는가. 저자들은 이것을 산성 시험이라 부른다. 이 질문에는 코드가 닮았는지가 들어 있지 않다. 몸체가 똑같은 두 함수가 각자 다른 규칙을 검증하고 있다면 한쪽 규칙이 바뀌어도 다른 쪽은 그대로이므로 중복이 아니다. 저자들은 이 경우를 우연의 일치라고 부른다. 이 구분을 진짜 중복(true duplication)과 우연한 중복(accidental duplication)이라고도 부른다. 한쪽을 바꿀 때마다 다른 쪽도 바꿔야 하면 진짜 중복이고, 겉보기에 닮은 두 코드가 다른 이유로 바뀌면 우연한 중복이다.

나이 규칙 세 곳 user.age < 19 "성인은 19세부터" 하나를 나눠 듦 나이 검증과 수량 검증 assertMin(value, 0) 나이 규칙과 수량 규칙을 각각 듦 사실 하나가 바뀌면 둘 다 고치는가 바뀐 사실을 몇 곳이 들고 있는가 진짜 중복 셋 다 고쳐야 함. 한 곳으로 모은다 우연한 중복 수량 쪽은 그대로. 둘을 그대로 둔다 모양 같음 모양 같음 예 아니오
몸체가 똑같은 두 함수가 "사실 하나가 바뀌면 둘 다 고치는가"에서 갈린다. 위는 같은 성인 기준을 들고 있어 진짜 중복으로, 아래는 나이 규칙이 바뀌어도 수량 규칙은 그대로라 우연한 중복으로 떨어진다.

왜 이렇게 정의했는가

정의가 코드가 아니라 지식을 기준으로 삼는 까닭은 저자들이 전제한 유지보수의 모습에 있다. 지식은 안정적이지 않고 계속 바뀐다. 요구사항을 이해한 내용이 달라지고, 규제가 바뀌면 비즈니스 로직이 낡는다. 그래서 유지보수는 출시 뒤의 별도 단계가 아니라 바뀐 지식의 표현을 찾아서 고치는 일상이다. 그러니 변경 한 건의 비용은 그 지식이 몇 곳에 표현되어 있는지에 비례하고, 한 곳을 놓치면 시스템이 서로 모순되는 지식 둘을 갖게 된다. 저자들의 말대로 기억할지가 아니라 언제 잊어버리는지의 문제다.

이 전제에서 판정 기준이 따라 나온다. 변경 비용을 정하는 것은 바뀌는 사실 하나당 손대야 하는 곳의 수이므로, 셀 것은 닮은 코드가 아니라 지식 하나에 딸린 표현이다. 모양이 전혀 다른 CSS 값과 JS 상수도 같은 결정을 담고 있으면 그 결정이 바뀔 때 둘 다 찾아야 하므로 비용에 그대로 잡힌다. 반대로 몸체가 똑같은 두 함수가 다른 규칙을 담고 있으면 한쪽 규칙이 바뀔 때 다른 쪽은 손댈 일이 없으므로 비용에 잡히지 않는다.

규칙 하나 (지식) 규칙을 적은 코드 셋 (표현) 18세로 바꾼 뒤 성인은 19세부터 회원 서비스 user.age < 19 주문 서비스 user.age < 19 리뷰 서비스 user.age < 19 회원 서비스 user.age < 18 주문 서비스 user.age < 18 리뷰 서비스: 옛 기준 남음 user.age < 19 세 곳에 적음 찾아서 고침 찾아서 고침 못 찾음
규칙 하나를 세 곳에 적어 두면 바꿀 때 세 곳을 모두 찾아야 하고, 하나만 놓쳐도 서비스마다 성인 기준이 달라진다.

같은 전제에서 모양만 보고 합치면 안 되는 이유도 나온다. 다른 지식 둘을 표현 하나에 욱여넣으면, 한쪽 지식만 바뀌는 날 매개변수와 분기가 덧대어지고 한 지식을 고칠 때 다른 지식의 분기까지 뒤져야 한다. 이것이 잘못된 추상화이고, DRY가 줄이려던 변경 비용이 다른 모양으로 되돌아온 것이다. 중복이 잘못된 추상화보다 훨씬 싸다는 말도 DRY와 충돌하지 않는다. 남기는 것은 코드 모양의 중복이고, DRY가 막는 것은 지식의 중복이기 때문이다.

지식 둘 합친 표현 하나 읽을 때 나이 규칙 13세 이상으로 바뀜 수량 규칙 그대로 0 이상 validate(value, kind) assertInteger(value) if (kind === 'age') assertMin(value, 13) else assertMin(value, 0) 한쪽만 바뀌었는데 분기가 생김 분기를 가려 읽음 바뀐 쪽 분기를 가려 읽음 안 바뀐 쪽까지 합침 합침 손대는 곳은 하나로 줄었지만, 그 한 곳 안에서 가려야 할 지식이 둘로 늘었다
다른 지식 둘을 표현 하나에 합치면 한쪽만 바뀌는 날 그 안에 분기가 생기고, 바뀌지 않은 지식까지 그 분기를 거쳐 읽어야 한다. 손대는 곳은 하나지만 한 곳 안에서 가려야 할 것이 둘로 늘어난다.

경계 사례

// users.service.ts
if (user.age < 19) throw new ForbiddenError('미성년자는 이용할 수 없음')
 
// orders.service.ts
if (user.age < 19) throw new ForbiddenError('미성년자는 이용할 수 없음')
 
// reviews.service.ts
if (user.age < 19) throw new ForbiddenError('미성년자는 이용할 수 없음')

성인 기준이 18세로 바뀌면 세 곳을 전부 고쳐야 하고, 하나를 놓치면 서비스마다 다른 기준이 적용된다. 사실 하나에 표현이 셋이므로 진짜 중복이고, 기준을 한 곳에 두고 세 서비스가 그것을 쓰게 바꾼다. DRY는 표현이 하나인지만 물으며, 그 한 곳이 상수인지 User 메서드인지는 응집도와 의존 방향 같은 다른 기준이 정한다.


// 사용자 나이가 0 이상의 정수인지 검증
function validateAge(value: unknown) {
  assertInteger(value)
  assertMin(value, 0)
}
 
// 주문 수량이 0 이상의 정수인지 검증
function validateQuantity(value: unknown) {
  assertInteger(value)
  assertMin(value, 0)
}

몸체가 한 글자도 다르지 않아서 리뷰에서 합치자는 말이 나오기 쉽다. 질문을 던져 보면 나이 규칙이 "13세 이상"으로 바뀔 때 수량 규칙도 같이 바뀌는 일은 없다. 두 함수는 나이와 수량이라는 다른 두 사실을 검증하고, 지금 규칙이 같은 것은 우연의 일치다. 표현 둘이 지식 둘을 각각 들고 있으므로 중복이 아니고, 합치면 한쪽 규칙이 바뀔 때 나이인지 수량인지 가르는 매개변수와 분기를 넣어야 한다. 저자들이 바로 이 모양의 코드를 두고 "코드는 같지만 지식이 다르다"고 썼다.


@media (max-width: 768px) {
  .nav {
    display: none;
  }
}
const isMobile = window.innerWidth <= 768

한 줄도 닮지 않았고 언어도 다르다. 하지만 모바일 기준 폭이 640픽셀로 바뀌면 두 곳을 다 고쳐야 하고, CSS만 고치면 내비게이션은 보이는데 JS는 모바일로 판단하는 구간이 생긴다. 사실 하나가 바뀔 때 두 곳을 고쳐야 하므로 진짜 중복이고, 복붙 탐지로는 잡히지 않는 종류다. 기준 폭을 토큰 파일 하나에 두고 빌드 시점에 CSS와 JS의 값을 생성하면 권위 있는 표현이 하나로 돌아온다.


// 서버 OpenAPI 스펙에 이미 정의된 응답을 손으로 다시 적음
interface OrderResponse {
  id: string
  status: 'paid' | 'shipped'
  total: number
}

서버가 status에 cancelled를 추가하면 스펙과 이 타입을 둘 다 고쳐야 한다. 서버의 지식을 클라이언트가 알아야 하는 이상 표현은 둘이 될 수밖에 없고, 이 중복 자체는 피할 수 없다. 피할 수 없는 중복이라도 사람이 고치는 권위 있는 표현을 스펙 하나로 두고 타입은 거기서 생성하면, 변경할 때 손대는 곳은 스펙 하나로 돌아온다. 손으로 쓴 타입과 생성한 타입은 파일 내용이 같아도 전자는 표현이 둘인 상태이고 후자는 권위 있는 표현이 하나인 상태다.

실제로 쓰는 문장

몸체가 같은 함수 둘을 합치자는 리뷰에 답할 때

"두 함수는 코드만 같고 담은 규칙이 다르다. 나이 규칙이 바뀌어도 수량 규칙은 그대로라서 DRY 대상이 아니다." 거부 근거를 "규칙이 다르다"에 두면 상대가 반박할 지점도 "정말 다른 규칙인가"로 좁혀진다. "지금은 같으니 합치자"는 모양을 근거로 한 말이라 여기에 답이 되지 않는다.

합치자고 제안할 때

"이 조건은 세 서비스가 같은 성인 기준을 각자 들고 있는 거라 기준이 바뀌면 셋 다 고쳐야 한다. 한 곳으로 모으자." 근거를 "닮아서"가 아니라 "같은 결정이라서"로 두면 합친 뒤 어디까지가 공통인지도 그 결정의 범위로 정해진다. 닮았다는 근거로 합치면 우연히 같았던 부분까지 공통 함수에 끌려 들어간다.

복붙이 없는 중복을 지적할 때

"미디어쿼리와 isMobile이 같은 기준 폭을 각자 들고 있다. 기준이 바뀌면 둘 다 고쳐야 하니 토큰 하나에서 파생시키자." 코드가 닮지 않아도 DRY 위반이라고 말하는 것이 정확하다. 이 지적이 가능해야 복붙 탐지 도구가 못 잡는 중복을 리뷰에서 잡는다.

피할 수 없는 중복을 다룰 때

"응답 타입을 손으로 쓰면 서버 스펙과 두 벌이 된다. 스펙에서 생성하면 권위 있는 표현은 스펙 하나로 남는다." 표현을 하나로 줄일 수 없을 때는 어느 쪽이 권위 있는 표현인지를 정하는 말이 DRY를 지키는 말이다. 생성된 파일이 저장소에 남아 있어도 사람이 고치는 곳이 하나면 DRY다.

근거