독학으로 개발을 배운 프런트엔드 개발자가 실제 코딩 중에 더 나은 결정을 내리도록 돕는, 즉시 적용 가능한 프로그래밍 원칙들을 정리한다.
많은 프런트엔드 개발자들처럼, 나 역시 정규 컴퓨터과학 배경이 없다. 디자이너로 시작해 최종 결과물에 대한 통제력을 더 갖고 싶어서 이 분야로 굴러들어왔고, ICT 학사 학위를 받긴 했지만 실제 공부는, 음, “기초 컴퓨터과학” 측면에서는 꽤 가벼웠다. 그래서 대문자 S의 ‘소프트웨어 개발’에 대해 내가 아는 것들은 여러 출처에서 일을 하며 그때그때 배운 것들이다. 당신도 그렇다면, 이 글이 몇 년치 시행착오를 아껴주길 바란다.
20년이 넘는 시간 동안 내 일상에 가장 큰 영향을 준 것들이 UML로 OOP 시스템을 모델링하는 법을 배웠다거나 모나드가 뭔지 안다는 사실은 아니었다. 대신 “프로그래밍 원칙”이라는 우산 아래 들어가는 (때로는 재치 있는) 문장들, 그런 것들이었다.
물론 세상에는 아주, 아주 많은 프로그래밍 원칙이 있다. 어떤 것들은 시스템과 사람이 어떻게 행동하는지를 설명하는 “법칙”에 가깝다(예: 호프스태터의 법칙: 호프스태터의 법칙을 고려하더라도, 항상 생각보다 더 오래 걸린다). 이런 것들은 더 넓은 맥락에서는 유용하지만, 내가 “좋은 코드”를 쓰고 싶을 때는 그리 실행 가능하다고 느끼지 못했다.
이 글에서는 코드를 작성하는 동안 더 나은 코드를 쓰는 데 도움이 되는 경험칙들을 다룬다. 시스템 전체를 미리 설계해야만 유용해지는 것들이 아니라, 진행하면서 더 좋은 결정을 내리게 도와주는 것들이다.
막 시작했을 때, 언젠가 누군가 당신 코드를 가리키며 “성급한 최적화는 모든 악의 근원이다(Premature optimisation is the root of all evil)”라고 말할 것이다. 굉장히 엄숙해 보이지만, 동시에 좀 이상하게 들리기도 한다.
최적화(optimisation)는 좋은 일인데 “성급한(premature)” 건 대개 좋지 않다. 큰 문제는 이제 당신이 하는 일이 ‘악’이라고 들었지만, 다음에 뭘 해야 하는지는 전혀 모르겠다는 점이다. 성급한 최적화를 피하라는 건 좋은 원칙이지만, 그 자체로는 더 나은 코드를 쓰는 데 직접 도움을 주진 못한다.
친절한 시니어 개발자라면 거기서 한 걸음 더 나아가 *너는 그게 필요 없을 거야(You Aren’t Gonna Need It, YAGNI)*를 설명해줄지도 모른다. 이것도 좋은 원칙으로, 지금은 필요하지 않지만 미래에 필요할 거라고 예상하며 코드를 미리 작성하지 말라고 경고한다. 왜 “필요 없게” 되는가? 지금 필요하지 않은 상태에서 미래의 ‘필요할 예정’인 시점까지 가는 동안 계획이 바뀔 가능성이 크고, 그 결과 실제로는 결국 필요하지 않게 되는 경우가 많기 때문이다. 그러니 정말 필요할 때 “적시에(just in time)” 작성하는 편이 낫다.
하지만 동시에 중복을 피하라(Don’t Repeat Yourself, DRY) 원칙도 따라야 한다고 듣는다. 여러 곳에서 같은 일을 하는 코드를 작성하지 말라는 것인데, 유지보수가 어렵기 때문이다. 이 원칙만 그대로 따르면, 결국 하는 일은 같은 코드를 한 함수나 모듈로 합쳐서 여러 곳에서 호출하는 것뿐일 것이다.
이 약어들은 대체로 좋은 조언이다. 기능을 만들 때 어느 정도는 미래를 예상하는 게 맞지 않을까? 그 로직이 여러 (미래의) 상황에서 필요할 것 같다면 미리 최적화하거나 리팩터링하는 게 말이 된다. 그러면 현재 코드가 그 상황을 고려하도록 만들 수 있고, 두 번 작성하지 않아도 된다.
YAGNI와 성급한 최적화가 언급되는 이유는, 지금 필요한 기능을 위한 코드를 쓰기보다, 지금 필요한 기능은 물론 미래에 필요할지도 모르는 기능까지 제공하는 일반 시스템을 먼저 만들기 시작하기 때문이다. 그러면 코드가 훨씬 더 많아지고, 복잡성이 늘며, 시니어 동료들보다 훨씬 오래 걸리게 된다.
이런 원칙들은 코드를 쓰는 동안 실제로 당신을 도와주지 못한다. 나중에 필요할 것 같은 부분을 랜덤하게 빼보는 식으로 시도할 수도 있지만, 무엇을 빼야 하는지 알려주는 원칙이 있으면 훨씬 좋다.
여기서 등장하는 것이 “3의 법칙(rule of three)”이다. 이는 YAGNI, DRY, 성급한 최적화에 대해 어떻게 생각해야 하는지를 우아하게 결합한, 실제로 적용 가능한 실용 원칙이다.
3의 법칙은 같은 코드를 세 번 작성한 뒤에야 리팩터링(또는 최적화)하라고 말한다. 첫 번째는 그냥 작성한다. 그 일만 하고 그 일만 한다. 두 번째로 같은 코드가 필요해지면… 말 그대로 복사-붙여넣기 한 다음 필요한 몇 가지 변경만 한다. 그리고 세 번째로 또 해야 할 때, 그때서야 이제 세 개가 된 구현을 보고 이들을 일반화하여 세 경우 모두를 처리할 수 있는 하나의 구현으로 만든다.
핵심은, 같은 코드를 세 번 작성하고 나면 실제로 필요한 일반 기능이 무엇인지, 어떤 부분을 단순화/최적화할 수 있는지 이해하게 된다는 점이다. 세 번 구현하고 나면 필요한 “추상화 수준(level of abstraction)”이 무엇인지 알게 되고, 일반화된 구현이 세 경우 모두에서 동작하도록 보장할 수 있다. 나는 이 원칙을 정말 자주 “적용”하는데, 너무 쉽기 때문이다(나는 3까지 셀 수 있다!). 그리고 과도한 설계를 막아주는 데 아주 효과적이다.
프로그래밍 원칙을 찾아보면 엄숙하게 들리는 법칙과 규칙을 쉽게 만날 수 있는데, 실제 적용에는 많은 맥락이 필요하다. 위 예시처럼 “성급한 최적화는 모든 악의 근원” 같은 일반 문장에서 “3의 법칙” 같은 구체 원칙으로 내려오면, 일상 코딩과 코드 품질에 훨씬 큰 영향을 준다는 걸 볼 수 있길 바란다.
이제 첫 번째 구현에 더 집중해보자. 처음부터 코드를 최적화하고 싶어지는 유혹은 매우 크다. 각 줄이 빠르고 효율적이도록 만들고 싶은 것이다. 하지만 빠르고 효율적인 코드는 종종 가장 읽기 쉬운 코드가 아니다.
코드를 작성할 때 실제로 우리는 쓰는 시간보다, 방금 쓴 코드나 이미 존재하는 코드를 읽고 다음에 무엇을 쓸지 추론하는 데 더 많은 시간을 쓴다. 따라서 코드가 읽고 추론하기 쉬울수록, 올바른 코드를 더 빠르게 작성할 수 있다. 빠르고 최적화된 코드는 훌륭하지만, 읽고 추론하기 어렵다면 장기적으로는 우리 속도를 떨어뜨린다.
우리는 다 원한다. 올바른 코드를 빠르게 쓰고 싶고, 그 코드 자체도 빠르길 바란다. 그럼 어떻게 선택해야 할까?
여기서 나는 켄트 벡(Kent Beck)—*익스트림 프로그래밍(Extreme Programming)*의 창시자—의 말로 알려진 또 다른 원칙을 적용한다. “작동하게 만들고(Make it work), 올바르게 만들고(Make it right), 빠르게 만들어라(Make it fast)”.
코딩 중 언제든 지금 코드에 대해 스스로에게 물어볼 수 있다: 작동하나(work)? 답이 ‘아니오’라면, 작동하게 만드는 데 집중한다. 다른 건 신경 쓰지 않는다. 일단 작동하게 만들어라. 작동하면: 좋다!
하지만 작동하는 것들이 항상 올바른 일을 하진 않는다. 그래서 다음 질문을 한다: 올바른가(right)? 코드가 올바르지 않은 동안(원하는 대로 동작하지 않거나, 테스트를 실패시키거나, 입력을 제대로 받지 못한다면) 올바르게 동작하도록 만드는 데 집중한다. 그리고 코드가 작동하고 올바를 때에만 묻는다: 빠른가(fast)? 충분히 빠르지 않다면, 이제 빠르게 만드는 데 집중할 수 있다.
이 원칙은 노력의 우선순위를 정하게 도와준다. 아직 작동하지 않는 코드나 잘못된 일을 하는 코드를 최적화하는 건 시간 낭비다. 어차피 다시 써야 하고, 그 과정에서 최적화도 사라진다. 코드가 아예 작동하지 않는다면, 그 망가진 코드가 올바른지 아닌지 고민할 필요도 없다. 망가졌으니 유용한 일을 하기 전에 먼저 작동하게 만들어야 한다.
이 원칙이 특히 유용한 이유는, 코드(의 동작)를 보기만 하면 언제든 적용할 수 있기 때문이다. 계획을 세울 필요가 없다. 그저 이 세 질문을 순서대로 던지고, 지금 중요한 질문 하나에만 집중하면 된다. 나머지는 관련 있어질 때까지 잊어도 된다.
자세히 보면, 이는 사실 3의 법칙과 같은 원칙을 프로그래밍의 다른 측면에 적용한 것이다. 둘 다 지금 당장 해야 할 일에 집중하게 해주며, 아직 관련 없는 다른 걱정거리들에 산만해지는 것을 막아준다.
첫 번째 구현을 작성하는 마음가짐을 돕는 원칙들이 여럿 있다. 이 부류의 원칙들은 첫 구현을 버리라고 하거나, 지금 당장에 맞는 최고의 단순한 시스템을 만들라고 말한다. 사실 3의 법칙에서의 “작동하게 만들기”가 바로 이 아이디어다.
더 고급스럽게 들리는 버전으로는 *갈의 법칙(Gall’s Law)*도 있는데, “작동하는 복잡한 시스템은 예외 없이 작동했던 단순한 시스템에서 진화한 것이다”라고 말한다.
핵심은 처음부터 모든 것을 고려하려 하면 결과물이 실제로는 작동하지 않는다는 점이다. 예를 들면, 더 나빠지고 계획보다 훨씬 오래 걸린 “전면 재작성(full rewrite)”은 역사상 수도 없이 많다. (물론 이것에도 이름이 있다: 두 번째 시스템 효과(Second-system effect).)
여기까지 읽으면 *단순하게 유지하라, 멍청아(Keep It Simple, Stupid, KISS)*가 떠오를지도 모른다. 하지만 KISS의 문제는, 사람들이 어떤 작업을 완료하도록 돕는 무언가를 만들다 보면 복잡성이 불가피한 경우가 있고, 그때 KISS는 답을 주지 못한다는 점이다. 어떤 것들은 단순하지 않다. 반면 갈의 법칙은, 단순하게 시작해 목표를 향해 반복적으로 개선한다는 점에 솔직하다면, 복잡한 것도 가능하다고 말해준다.
그렇다 해도, 코드를 쓰는 동안 직접적으로 실행하기엔 아직 좀 막연하다. 그래서 이제 정말로 코드를 쓰는 순간에 더 나은 코드를 쓰도록 도와주는 원칙들을 찾아보자.
가능한 한 내가 작성하는 함수들은 *멱등(idempotent)*하게 만든다. 큰 단어다. 하지만 의미는 간단하다. 같은 인자를 주면 언제나 같은 일을 하는 함수라는 뜻이다. 획기적이진 않아 보이지만, 코드의 추론 가능성에 꽤 큰 영향을 준다.
멱등 함수라면, 같은 인자로 몇 번을 호출해도 항상 같은 결과를 반환한다. 예를 들어 문자열 길이를 가져오는 것은 멱등이다. 몇 번을 호출하든 "hello".length는 언제나 5를 반환한다. 함수가 멱등이라고 알고 있으면, 몰래 새 문자열을 반환한다거나 코드 어딘가의 변수를 읽는지 걱정할 필요가 없다. 같은 입력, 같은 출력.
멱등 함수도 부수 효과(side effect)를 가질 수 있다. 단, 그 부수 효과 역시 멱등이어야 한다. 전역 상태나 데이터베이스 엔트리를 바꾸는 것도, 함수를 여러 번 호출해도 항상 같은 값이 설정되는 것이라면 멱등일 수 있다.
그렇지 않다면, 함수가 두 번 호출되는 상황(사용자의 더블클릭이나 불안정한 연결에서 서버의 재시도 로직 등)에서 결과 값이 달라질 수 있다. 이는 코드 추론을 훨씬 어렵게 만든다. 부수 효과가 필요하다고 느껴진다면, 그 부분을 별도 함수로 분리하고 그 함수를 멱등으로 만들어라. 큰 함수 하나 대신 각각 한 가지 일을 하는 작은 함수 두 개를 갖게 된다.
멱등 함수는 시스템을 추론할 때 구현 세부사항을 “잊게 해주는” 블랙박스처럼 다룰 수 있다. 더 높은 수준에서 생각하기 쉽게 해주는 뇌의 지름길 같은 것이다. 함수는 항상 같은 일을 하므로, 머릿속에서 그 함수를 하나의 단계로 접어 넣을 수 있다.
멱등성과 관련된 또 다른 원칙이 *단일 책임 원칙(Single Responsibility Principle)*이다. 이 원칙은 함수(또는 모듈, 클래스)가 “변경되어야 할 이유가 단 하나만 있어야 한다”고 말한다.
실제로는 시스템의 한 측면을 하나의 함수가 책임져야 한다는 뜻이다. 좋은 예로 데이터베이스 접근을 전담하는 *ORM(Object-Relational Mapping)*을 들 수 있다. ORM 모듈은 DB와 대화하는 책임을 가지며, 나머지 코드는 그것이 어떻게 동작하는지 알 필요가 없어야 한다. DB와 대화하는 방식을 바꿔야 한다면 ORM만 수정하면 되고, 다른 코드는 건드리지 않아야 한다.
이 역시 시스템을 추론할 때 일부를 접어 넣게 해준다. SQL 쿼리를 어떻게 구성하는지 알 필요가 없다. 지금 작업 중인 코드의 책임이 아니기 때문이다. ORM을 그저 DB 접근을 해주는 블랙박스로 취급하면 된다.
단일 책임 원칙은 “함수/모듈/클래스는 존재해야 할 이유가 하나만 있어야 한다”고도 표현된다. 그 단 하나의 이유가 사라진다면, 그 함수/모듈/클래스를 통째로 제거할 수 있어야 한다. 예를 들어 다른 DB로 바꾸면서 새로운 ORM이 필요해진다면, 기존 ORM은 완전히 제거 가능해야 한다.
하지만 흔히 ORM이 기능이 늘어나면서 DB에서 데이터를 가져오는 것뿐 아니라, 나머지 코드가 기대하는 특정 형식으로 데이터를 마구 변형하기도 한다. 그러면 DB를 바꾸고 싶을 때 그 특정 데이터 형식에 의존하는 모든 코드도 함께 수정해야 한다. ORM에는 변경 이유가 여러 개가 생기고, 이는 추론을 어렵게 만든다.
대신 DB에서 데이터를 가져오는 것만 담당하는 모듈 하나, 데이터 포맷팅만 담당하는 모듈 하나를 두어야 한다. 그러면 각 모듈은 단일 책임을 가지며, 하나를 바꾸거나(혹은 삭제해도!) 다른 하나에 영향을 주지 않는다. 이것이 단일 책임 원칙의 실제 적용이다.
단일 책임을 점검하는 작은 요령은 함수/모듈/클래스가 하는 일을 한 문장으로 설명해보는 것이다. 설명하다가 “그리고(and)”가 나오면, 책임이 여러 개일 가능성이 높고 분리해야 한다.
단일 책임 원칙과 관련된 또 다른 아이디어는, 함수가 한 가지 일만 해야 할 뿐 아니라 하나의 추상화 수준에서만 동작해야 한다는 것이다. “추상화 수준”이라는 말 자체가 꽤 추상적이긴 하지만, 의미는 이렇다. 함수 안의 코드를 읽을 때, 모든 연산이 같은 수준의 디테일이어야 한다.
예를 들어 다음 함수를 보자:
jsasync function processUsers() { const users = await database.fetchAllUsers(); users.forEach((user) => { if (user.isActive) { sendEmail(user.email, "Hello active user!"); } }); }
이 함수에는 세 가지 추상화 수준이 섞여 있다:
database.fetchAllUsers()).if (user.isActive)).sendEmail(...)).이 함수를 설명하려면 “그리고”가 많이 필요할 뿐 아니라—DB에 연결해 사용자를 가져오고 그리고 필터링하고 그리고 필터링된 사용자에게만 이메일을 보내고—함수를 이해할 때도 세 가지 서로 다른 디테일 수준(DB, 활성 사용자, 이메일 전송)을 동시에 생각해야 한다. 실제 코드라면 await가 더 많아지고, 검증과 에러 처리도 추가될 수 있다.
함수를 읽는 동안 집중해야 할 대상이 계속 바뀌어서 따라가기 힘들어진다. 예를 들어 처음에는 모든 사용자를 가져오는 이야기였다가, 다음은 필터링 이야기로 바뀌고, 다음은 이메일 전송 이야기로 바뀐다.
여러 추상화 수준이 섞였다는 신호로는 다음을 살펴볼 수 있다:
이를 분리하면, 각각 단일 추상화 수준에서 동작하는 세 함수를 만들 수 있다:
jsasync function getActiveUsers() { const users = await database.fetchAllUsers(); return users.filter((user) => user.isActive); } function sendEmailsToUsers(users) { users.forEach((user) => { sendEmail(user.email, "Hello active user!"); }); } function processUsers() { const activeUsers = getActiveUsers(); sendEmailsToUsers(activeUsers); }
getActiveUsers는 사용자 가져오기와 활성 사용자 필터링만 다루고, sendEmailsToUsers는 이메일 전송만 다루며, processUsers는 활성 사용자를 가져온 뒤 그들에게 이메일을 보낸다. 각 함수는 하나의 추상화 수준만 다루기 때문에 이해하기 쉽고, 각 부분을 따로 추론할 수 있다.
리팩터링된 버전에서 sendEmailsToUsers는 사용자가 어디서 왔는지나 어떤 상태인지에 관심이 없다는 점에 주목하자. 주어진 사용자들에게 이메일을 보내기만 한다. 각 함수는 단일 책임을 가지며 단일 추상화 수준에서 동작한다.
여기까지 오면, 이 원칙들 상당수가 서로 관련되어 있다는 걸 눈치챘을지 모른다. 그 이유는, 고전을 살짝 비틀어 말하자면:
모든 좋은 코드는 서로 비슷하다; 나쁜 코드는 저마다 자기만의 방식으로 나쁘다.
— 톨스토이(그가 소프트웨어 엔지니어였다면)
좋은 코드는 추론하기 쉽고, 각 부분이 무엇을 하는지 명확하면 추론하기도 쉽다. 또한 각 부분이 서로 어떻게 연결되는지도 명확하다. 그래서 이 원칙들은 “추론하기 쉬운 코드”를 쓰는 여러 측면을 강조한다.
반대로, 어떤 코드가 왜 나쁜지 파악하는 것은 어렵다. 책임이 여러 개인가? 최적화돼 있지만 틀렸나? 줄줄 읽어 내려가며 여러 추상화 수준을 관광시키나? 뭔가 불필요한 일을 하는데 왜 하는지 모르겠나? 이런 것들이 나쁜 코드를 다루기 어렵게 만든다.
나쁜 코드를 좋은 코드로 고치는 것보다, 위 원칙들을 (교조적으로라도) 지키며 처음부터 좋은 코드에 가깝게 가는 편이 훨씬 쉽다.
다음에 새로운 함수를 작성할 때, 이 원칙들을 떠올리길 바란다. 아직 작동하지 않는 코드를 최적화하려는 자신을 붙잡고, 각 함수가 한 가지 일만 하도록 만들고, 단순하게 작동하는 시스템을 갖기 전에 복잡한 시스템을 설계하는 일을 피하길 바란다. 행운을 빈다!
말했듯이, 나는 이 모든 걸 일을 하며 그때그때 배웠다. 그 과정에서 도움이 되었던 자료들이다:
이 글이 마음에 들었나요? Open Collective을 통해 팁을 남겨 응원할 수 있어요.