YAGNI
- YAGNI
- 추정 기능
- presumptive feature
- 과도한 설계
- over-engineering
한 줄 결론
YAGNI는 아직 없는 요구사항을 짐작해 기능을 미리 만들지 말라는 원칙이다. 인터페이스나 의존성 주입 같은 구조는 기능을 늘리지 않고 코드를 바꾸기 쉽게만 만들기 때문에 YAGNI로 판단할 대상이 아니다. 구현체가 하나뿐이라는 모양만 보고 인터페이스를 YAGNI를 이유로 빼자고 하면 기능을 재는 원칙을 구조에 갖다 댄 것이라 틀린 말이다.
질문 바로잡기
구현체가 하나뿐인 인터페이스를 보고 "YAGNI니까 빼자"고 말하는 리뷰가 있다. YAGNI는 기능을 재는 원칙이고 구현체가 몇 개인지는 기능과 무관하므로, 이 리뷰는 대상을 잘못 짚었다. 기능은 사용자나 호출자가 쓰는 동작이다. 인터페이스는 보통 그 동작을 늘리려고 두는 것이 아니라 코드를 테스트하고 고치는 방식을 바꾸려고 둔다. 테스트에서 가짜 구현체로 바꿔 끼우게 해 주고, 호출하는 쪽을 건드리지 않고 구현을 갈아 끼울 수 있게 한다. 그 쓸모로 둔 인터페이스가 지금 안 쓰이고 있다면 설계가 과한 것이지 YAGNI 위반은 아니다.
그런데도 인터페이스에 YAGNI를 갖다 대는 데는 이유가 있다. "그거 필요 없을 거야"라는 이름은 무엇이 필요 없다는 건지 대상이 비어 있어서, 아무 코드에나 붙일 수 있는 말처럼 들린다. 언젠가 DB를 바꿀 때를 대비한 저장소 계층, 확장할 사람이 없는 플러그인 시스템처럼 흔히 드는 YAGNI 위반 예시까지 전부 추상화 모양을 하고 있다. 그런 예시만 계속 보다 보니 추상화 자체가 YAGNI 위반처럼 보이게 됐다. 그러나 이 예시들은 추상화라서 문제가 아니라 아직 없는 요구사항을 숨기고 있어서 문제다. 판정할 때 보는 것은 추상화인지가 아니라 그 뒤에 요구사항이 있는지다.
그래서 어떤 코드를 판정할 때는 모양이 아니라 그 코드가 지금 쓰이는지를 보고, 안 쓰인다면 무엇을 대비해 둔 것인지를 봐야 한다.
무엇을 뜻하는가: 추정 기능
YAGNI가 금지하는 것은 추정 기능이다. XP 팀에서 나온 이 말의 원래 뜻은 실제로 필요할 때 구현하고 필요할 것 같다는 예감만으로는 구현하지 말라는 것이다. Martin Fowler는 여기서 "필요한 것"을 추정 기능, 즉 아직 사용자에게 열리지 않은 기능을 뒷받침하는 코드로 좁혔다. 존재하는 근거가 "나중에 분명 필요할 것 같다"는 예측뿐인 기능은 모두 추정 기능이다.
어떤 코드가 추정 기능인지는 질문 두 개로 가린다. 첫 번째는 그 코드가 지금 쓰이고 있는가다. 쓰이고 있으면 기능이든 구조든 YAGNI의 대상이 아니다. 두 번째는 아직 안 쓰이는 코드에만 묻는 질문으로, 무엇을 대비해 둔 것인가다. 사용자나 호출자가 보는 동작을 늘리거나 바꾸려고 둔 것이면 기능이고, 코드를 테스트하고 고치기 쉽게 하려고 둔 것이면 구조다. 기능이면 아직 없는 요구사항을 대비한 추정 기능이라 YAGNI가 뺀다. 구조이면 과한 설계일 수는 있어도 YAGNI의 대상은 아니고, 남길지 뺄지는 다른 기준으로 정한다.
왜 이렇게 정의했는가
추정 기능이 손해인 까닭을 Fowler는 비용 네 가지로 나눠 설명한다. 만드는 비용(cost of build)은 분석하고 구현하고 테스트하는 시간이다. 지연 비용(cost of delay)은 그 시간에 지금 필요한 기능을 못 만든 기회비용이다. 들고 다니는 비용(cost of carry)은 그 코드가 있는 동안 다른 기능을 만들 때마다 읽고 피해 가야 하는 복잡도다. 고치는 비용(cost of repair)은 예측이 어긋났을 때 걷어내거나 실제 요구사항에 맞게 다시 짜는 수고다. 예측이 맞아도 지연 비용과 들고 다니는 비용은 이미 낸 뒤다. 여섯 달 전에 짐작으로 짠 코드가 지금 요구사항과 그대로 맞는 일도 드물어 고치는 비용까지 따라온다.
이 비용 비교에는 전제가 있다. YAGNI는 익스트림 프로그래밍(XP) 실천법 묶음의 하나로 나왔고, 다른 실천법 없이 혼자 성립하지 않는다. YAGNI가 성립하는 조건은 코드 변경 비용이 낮게 유지되는 것이다. 전통적인 개발은 변경이 늦을수록 비용이 기하급수로 커진다고 봤는데, XP는 그 곡선을 평평하게 만들려 한다. 리팩터링으로 설계를 계속 고쳐 나가고, 자동화된 테스트가 그 변경을 받쳐 주고, 지속 통합으로 통합 비용을 작게 쪼갠다. Fowler는 이 셋을 진화적 설계의 전제로 꼽았다.
이 조건이 갖춰지면 기능을 나중에 추가하는 비용이 미리 만들어 두는 비용보다 거의 항상 작다. 요구사항이 실제로 나타난 뒤에 만들면 무엇을 만들어야 하는지 명확해져 설계도 더 낫게 나온다. 반대로 이 실천법들이 없으면 YAGNI는 저주가 된다고 Fowler는 경고한다. 변경이 비싼 코드에서는 나중에 만드는 쪽이 오히려 더 비싸기 때문이다.
여기서 "구조는 대상이 아니다"가 따라 나온다. 변경 비용을 낮게 유지하는 수단을 YAGNI로 걷어내면 YAGNI가 서 있는 전제가 무너진다. 테스트를 위한 인터페이스 분리, 의존성 주입, 모듈 경계를 "지금 필요 없다"며 빼면 코드가 바꾸기 어려워지고, 바꾸기 어려운 코드에서는 나중에 만들기가 비싸진다. 나중에 만들기가 비싸지면 미리 만들어 두는 쪽이 유리해지고, 그 순간 YAGNI는 틀린 조언이 된다. 그래서 Fowler는 YAGNI의 범위를 추정 기능을 뒷받침하는 코드로 한정하고, 소프트웨어를 바꾸기 쉽게 만드는 노력은 거기서 제외했다.
이 구조를 알면 판별 기준을 외우지 않아도 된다. 어떤 코드를 두고 고민될 때 이 코드를 지우면 나중에 바꾸는 일이 더 비싸지는지, 아니면 이 코드를 들고 다니는 일이 지금 더 비싼지를 보면 된다. 지우면 나중이 더 비싸지는 코드는 남기고, 들고 다니는 지금이 더 비싼 코드는 뺀다. 구조는 대개 남기는 쪽으로, 추정 기능은 대개 빼는 쪽으로 갈린다.
경계 사례
interface PostRepository {
findBySlug(slug: string): Promise<Post | null>
}
class FilePostRepository implements PostRepository {}
class InMemoryPostRepository implements PostRepository {} // 테스트에서만 사용운영 구현체는 하나뿐이고 두 번째 구현체는 테스트 디렉터리에만 있다. 테스트가 지금 InMemory 구현체를 끼워 쓰고 있으니 이 인터페이스는 첫 번째 질문에서 끝나고, YAGNI가 판정할 대상이 아니다. 그래도 빼고 싶다면 근거는 "테스트에서 구현체를 바꿔 끼울 필요가 정말 있는가"이고, YAGNI가 아니다.
interface PostRepository {
findBySlug(slug: string): Promise<Post | null>
}
class FilePostRepository implements PostRepository {}
// "나중에 DB로 옮길 수도 있어서" 인터페이스를 둠. 두 번째 구현체는 없음모양은 위와 같은데 두 번째 구현체가 없고, 인터페이스는 언젠가 데이터베이스를 바꿀 때를 대비해 둔 것이다. 지금 이 인터페이스로 구현체를 바꿔 끼우는 곳은 없으니 두 번째 질문으로 넘어간다. 이 인터페이스가 대비한 것은 DB 교체, 즉 데이터가 저장되는 곳이 바뀌는 일이고, 그것은 코드를 고치는 방식이 아니라 소프트웨어의 동작이 바뀌는 일이다.
그래서 이 인터페이스는 구조의 모양을 한 추정 기능이다. 같은 코드가 첫 사례에서는 대상이 아니고 여기서는 추정 기능이다. 판정 기준은 코드 모양이 아니라 그 코드가 지금 쓰이는지, 안 쓰인다면 무엇을 대비했는지다. 교체 요구가 실제로 잡히는 날 인터페이스를 추출하면 되고, 그때는 어떤 메서드가 필요한지도 지금보다 명확하다.
class UserService {
private readonly emitter = new EventEmitter()
async createUser(data: CreateUserDto) {
const user = await this.repository.save(data)
this.emitter.emit('user.created', user) // 구독자가 하나도 없음
return user
}
}이벤트를 발행하지만 구독하는 코드가 없다. 발행은 "누군가 나중에 이 시점에 끼어들고 싶어질 것"이라는 예측 하나로 존재한다. 구독자가 생기는 순간이 기능이 생기는 순간이고, 그 전까지 발행 코드는 추정 기능이다. 들고 다니는 비용만 내고 있는 상태라서 지우는 쪽이 맞다. 구독자가 생기면 그때 발행 한 줄을 다시 넣으면 된다.
ALTER TABLE orders ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'KRW';지금은 원화 결제만 있는데 통화 컬럼을 넣자는 제안이다. 다중 통화는 아직 없는 요구사항이므로 추정 기능이지만, 이 컬럼은 지금 넣는 쪽이 맞다. 운영 중인 테이블에 컬럼을 나중에 추가하려면 마이그레이션, 기존 행 채우기, 원화를 전제로 쌓인 코드 수정까지 따라와서, 들고 다니는 비용보다 나중에 바꾸는 비용이 훨씬 크다.
YAGNI가 "빼라"고 하는 근거는 비용 비교이므로, 바로 앞의 이벤트 발행과 달리 비교 결과가 뒤집히면 결론도 "넣는다"로 바뀐다. 단, 넣는 것은 컬럼 하나까지다. 통화 변환 로직까지 미리 짜는 것은 여전히 추정 기능이다.
실제로 쓰는 문장
교체 대비용 추상화를 거부할 때
"교체 요구가 없는데 저장소 인터페이스를 두면 추정 기능이다. 교체가 실제로 잡히면 그때 추출하자." 거부 이유를 "뒤에 있는 요구사항이 아직 없어서"로 두면 같은 모양의 테스트용 인터페이스까지 걷어내는 실수를 피한다. "추상화라서"를 이유로 들면 그 실수가 바로 따라온다.
YAGNI를 근거로 구조를 빼자는 말에 답할 때
"이 인터페이스는 기능을 늘리지 않고 지금 테스트에서 구현체를 바꿔 끼우는 데 쓰고 있어서 YAGNI 대상이 아니다. 빼자면 테스트 쪽 근거로 이야기하자." 적용 범위 밖이라고 말하는 것이 정확하다. 상대는 원칙을 맞게 알고 있고 대상만 잘못 짚었기 때문이다.
복잡하다는 지적에 YAGNI가 붙었을 때
"이 기능은 지금 쓰고 있어서 YAGNI 대상이 아니다. 복잡한 건 구현이니 단순화로 이야기하자." YAGNI는 기능의 유무를 묻고, 지금 있는 기능의 구현이 과하게 복잡한 문제는 KISS가 다룬다. 둘을 섞으면 "기능을 빼자"와 "구현을 단순하게 하자"가 한 리뷰 코멘트에 뒤섞여 어느 쪽으로 고쳐야 할지 모르게 된다.
되돌리기 비싼 결정 앞에서
"스키마는 나중에 바꾸는 비용이 커서 YAGNI의 비용 비교가 반대로 나온다. 지금 어디까지 넓게 잡을지 정하자." YAGNI를 어기는 예외가 아니라 YAGNI가 전제하는 비용 비교를 그대로 거친 결과라고 말하는 것이 맞다. 나중에 바꾸는 쪽이 훨씬 비싼 변경을 떠안는 경우가 있다는 것은 Fowler도 Yagni 글에서 인정한다.
근거
- Ron Jeffries, You're NOT gonna need it! (1998): https://ronjeffries.com/xprog/articles/practices/pracnotneed/
- Martin Fowler, Yagni (2015): https://martinfowler.com/bliki/Yagni.html
- Martin Fowler, Is Design Dead? (2000, 2004 개정): https://martinfowler.com/articles/designDead.html