Skip to content
Published on

프로그래밍을 잘하는 법 — 원리, 습관, 그리고 깊이

Authors

들어가며: 잘함이라는 말의 무게

"프로그래밍을 잘하고 싶다"는 말은 누구나 합니다. 하지만 막상 무엇이 잘하는 것이냐고 물으면 대답이 흐려집니다. 어떤 사람은 알고리즘 문제를 빠르게 푸는 것을 떠올리고, 어떤 사람은 새 프레임워크를 빨리 익히는 것을 떠올립니다. 또 어떤 사람은 화면에 멋진 결과를 빨리 띄우는 것을 떠올립니다.

이 글은 그 흐릿한 정의를 또렷하게 만드는 데서 출발합니다. 저는 화려한 비법이나 "이것만 알면 된다" 식의 약속을 하지 않을 것입니다. 그런 약속은 대개 거짓이거나, 적어도 절반만 진실입니다. 대신 오래 일한 사람들이 공통적으로 가진 원리와 습관, 그리고 그 습관을 기르는 구체적인 방법을 이야기하려 합니다.

미리 한 가지를 밝혀 둡니다. 잘하는 것은 재능의 문제라기보다 누적의 문제입니다. 피터 노빅이 "Teach Yourself Programming in Ten Years"에서 지적했듯, 깊이는 짧은 시간에 만들어지지 않습니다. 그러나 같은 10년을 보내도 누적의 질은 사람마다 크게 다릅니다. 이 글은 그 질을 높이는 방법에 관한 것입니다.

1. 잘함의 정의: 동작하고, 읽히고, 유지된다

먼저 정의부터 합의합시다. 저는 잘 만든 코드를 세 가지 층위로 봅니다.

층위질문실패했을 때의 증상
동작한다의도한 일을 정확히 하는가버그, 잘못된 결과, 엣지 케이스 누락
읽힌다다른 사람이 이해할 수 있는가리뷰 지연, 잦은 질문, 오해
유지된다변경에 잘 견디는가작은 수정이 연쇄 장애를 부름

초심자는 첫 번째 층위만 봅니다. 동작하면 끝이라고 생각하지요. 그러나 현업에서 코드의 수명은 깁니다. 한 번 쓰고 버리는 코드는 드뭅니다. 6개월 뒤의 나, 1년 뒤의 동료가 그 코드를 다시 읽고 고칩니다. 그래서 두 번째와 세 번째 층위가 결정적입니다.

브라이언 커니핸의 유명한 말이 있습니다. 디버깅은 코드를 작성하는 것보다 두 배 어렵다. 그러니 가능한 한 영리하게 코드를 쓰면, 정의상 당신은 그것을 디버깅할 만큼 똑똑하지 않다는 뜻입니다. 잘함의 정의에 "겸손"이 들어가는 이유가 여기 있습니다.

2. 기본기: 자료구조와 시스템에 대한 이해

기본기라는 말은 종종 면접용 알고리즘으로 오해됩니다. 하지만 진짜 기본기는 두 가지 감각입니다. 첫째, 자료가 메모리에서 어떻게 표현되고 이동하는가. 둘째, 내가 만든 코드가 실제 기계와 네트워크 위에서 어떻게 실행되는가.

2.1 자료구조는 선택의 문제다

자료구조를 외우는 것은 의미가 없습니다. 중요한 것은 상황에 맞는 선택입니다. 다음 예를 봅시다. 리스트에서 중복을 제거하는 두 방식의 시간 복잡도가 어떻게 다른지 감이 와야 합니다.

# O(n^2) — 리스트 안에서 in 검사를 반복
def dedup_slow(items):
    result = []
    for x in items:
        if x not in result:   # 매번 선형 탐색
            result.append(x)
    return result

# O(n) — 집합의 해시 조회를 활용
def dedup_fast(items):
    seen = set()
    result = []
    for x in items:
        if x not in seen:     # 평균 상수 시간
            seen.add(x)
            result.append(x)
    return result

두 함수는 같은 결과를 냅니다. 그러나 입력이 10만 건이 되면 첫 번째는 사실상 멈추고, 두 번째는 눈 깜짝할 사이에 끝납니다. 기본기란 이 차이를 코드를 보자마자 느끼는 능력입니다. 외운 복잡도 표가 아니라, 자료가 어디에 어떻게 담기는지를 떠올리는 습관입니다.

2.2 시스템에 대한 이해

웹 개발자라면 HTTP 요청 하나가 어떤 경로를 거치는지 그릴 수 있어야 합니다. 다음은 단순화한 흐름입니다.

브라우저
  -> DNS 조회 (도메인 -> IP)
  -> TCP 연결 / TLS 핸드셰이크
  -> HTTP 요청 전송
        -> 로드 밸런서
        -> 애플리케이션 서버
        -> 캐시 조회 (히트면 여기서 응답)
        -> 데이터베이스 쿼리
  <- 응답 직렬화 (JSON 등)
  <- 렌더링

이 그림을 머릿속에 가지고 있으면, "왜 느린가"라는 질문 앞에서 막연해지지 않습니다. 캐시를 의심할지, 쿼리를 의심할지, 직렬화를 의심할지 후보를 좁힐 수 있습니다. 시스템 이해란 결국 문제의 위치를 좁히는 지도입니다.

3. 디버깅: 추측이 아니라 관찰

많은 사람이 디버깅을 운이라고 생각합니다. 코드를 이리저리 바꿔 보다 우연히 고쳐지면 다행이라고요. 그러나 잘하는 사람의 디버깅은 과학에 가깝습니다. 가설을 세우고, 관찰로 검증하고, 후보를 절반씩 줄여 나갑니다.

3.1 디버깅의 기본 루프

1. 재현한다     — 항상 같은 조건에서 같은 증상이 나오게 만든다
2. 좁힌다       — 문제가 있는 구간을 절반씩 잘라 본다 (이분 탐색)
3. 가설을 세운다 — "이 변수가 null일 것이다" 같은 검증 가능한 문장
4. 관찰한다     — 로그, 디버거, 출력으로 가설을 확인한다
5. 고친다       — 원인을 고치고, 증상이 아니라 원인이 사라졌는지 본다
6. 방지한다     — 같은 종류의 버그를 막을 테스트나 가드를 남긴다

이 루프에서 가장 자주 건너뛰는 단계는 1번 재현입니다. 재현되지 않는 버그를 고치려는 시도는 어둠 속에서 칼을 휘두르는 일입니다. 먼저 안정적인 재현 절차를 만드는 데 시간을 쓰는 것이 결국 가장 빠른 길입니다.

3.2 이분 탐색식 디버깅

긴 처리 과정 어딘가에서 값이 망가진다면, 가운데에 관찰 지점을 하나 박아 넣습니다.

def process(records):
    cleaned = clean(records)
    # 관찰 지점: 여기서 값이 정상인가?
    assert all(r.get("id") for r in cleaned), "id가 비어 있다"
    enriched = enrich(cleaned)
    return summarize(enriched)

만약 assert가 통과하면 문제는 enrich 이후에 있고, 실패하면 clean에 있습니다. 한 번의 관찰로 후보 공간이 절반이 됩니다. 이것이 추측과 과학의 차이입니다.

4. 추상화와 단순함: 적을수록 좋다

추상화는 양날의 검입니다. 좋은 추상화는 복잡함을 숨겨 인지 부담을 줄입니다. 나쁜 추상화는 복잡함을 옮기기만 하고, 거기에 또 한 겹의 간접성을 더합니다.

리치 히키는 "Simple Made Easy" 강연에서 단순함(simple)과 쉬움(easy)을 구분했습니다. 쉬움은 익숙함의 문제이고, 단순함은 얽힘의 문제입니다. 한 가지 일만 하는 것은 단순한 것이고, 손에 익은 것은 쉬운 것입니다. 우리는 종종 쉬운 것을 좇다가 얽힌 것을 만듭니다.

4.1 성급한 추상화의 함정

다음은 흔한 실수입니다. 두 곳에서 비슷한 코드를 보고 곧바로 공통 함수로 묶는 것이지요.

# 두 곳에서 쓰인다는 이유로 묶은 함수
def handle(entity, kind):
    if kind == "user":
        validate_user(entity)
        save_user(entity)
    elif kind == "order":
        validate_order(entity)
        send_invoice(entity)
        save_order(entity)
    # kind가 늘 때마다 분기가 늘고, 두 흐름이 서로를 오염시킨다

표면적인 유사함에 속아 묶으면, 시간이 지나며 분기가 자라고 함수는 누구의 것도 아닌 괴물이 됩니다. 두 번 반복이 보일 때는 차라리 그대로 두는 편이 낫습니다. 세 번째가 나타날 때 비로소 진짜 공통점이 무엇인지 보입니다. "성급한 추상화보다 약간의 중복이 낫다"는 격언이 여기서 나옵니다.

4.2 단순함의 측정

단순함은 느낌이 아니라 셀 수 있는 것입니다. 한 함수가 다루는 개념의 수, 들어오는 인자의 수, 가능한 상태의 수를 세어 보세요. 다음 표는 거친 신호입니다.

신호단순함 쪽복잡함 쪽
함수 인자 수3개 이하6개 이상
불리언 인자없음동작을 바꾸는 플래그가 여럿
분기 깊이2단 이하4단 이상 중첩
한 함수의 책임하나여럿 (이름에 and가 들어감)

이름에 and가 들어간다면 그 함수는 둘로 나뉘어야 한다는 신호입니다. validateAndSave라는 이름을 본다면, 검증과 저장은 서로 다른 이유로 변하는 일이라는 점을 기억하세요.

5. 읽기 좋은 코드: 코드는 사람을 위해 쓴다

컴파일러는 어떤 변수명도 받아들입니다. 변수명은 오직 사람을 위한 것입니다. 그래서 좋은 코드는 산문처럼 읽힙니다.

5.1 이름이 절반이다

# 나쁨: 의도가 이름에 없다
def f(d, n):
    return [x for x in d if x[1] > n]

# 좋음: 이름이 코드를 설명한다
def filter_above_threshold(records, threshold):
    return [r for r in records if r.score > threshold]

두 함수의 동작은 같지만, 아래 함수는 주석이 필요 없습니다. 좋은 이름은 가장 값싼 문서입니다. 그리고 이름은 처음부터 완벽할 필요가 없습니다. 의미가 또렷해지면 곧바로 고치는 습관이 중요합니다.

5.2 주석은 왜를 적는다

코드 자체가 무엇을(what) 하는지는 보여 줍니다. 주석은 왜(why)를 적어야 합니다.

# 나쁨: 코드를 그대로 옮긴 주석
i = i + 1  # i를 1 증가시킨다

# 좋음: 코드만으로는 알 수 없는 이유
# 외부 API가 0부터가 아니라 1부터 페이지를 센다
page = page + 1

"무엇"을 설명하는 주석은 코드가 바뀌면 거짓말이 됩니다. "왜"를 설명하는 주석은 오래 살아남아 미래의 독자를 구합니다.

6. 테스트: 미래의 나를 위한 보험

테스트는 귀찮은 의무가 아니라 설계 도구입니다. 테스트하기 어려운 코드는 대개 설계가 나쁜 코드입니다. 의존성이 뒤엉켜 있거나, 한 함수가 너무 많은 일을 하기 때문입니다.

6.1 좋은 테스트의 모양

# 대상 함수
def apply_discount(price, rate):
    if not 0 <= rate <= 1:
        raise ValueError("rate는 0과 1 사이여야 한다")
    return round(price * (1 - rate), 2)

# 테스트: 경계와 예외를 함께 본다
def test_apply_discount():
    assert apply_discount(100, 0.0) == 100.0     # 할인 없음
    assert apply_discount(100, 0.2) == 80.0      # 일반 경우
    assert apply_discount(100, 1.0) == 0.0       # 전액 할인 경계

    import pytest
    with pytest.raises(ValueError):
        apply_discount(100, 1.5)                  # 잘못된 입력

좋은 테스트는 정상 경우만 보지 않습니다. 경계값과 잘못된 입력을 함께 봅니다. 버그는 대개 그 가장자리에서 자라기 때문입니다.

6.2 테스트 피라미드

        /\
       /  \      E2E 테스트 (적게)  — 느리지만 실제와 가깝다
      /----\
     /      \    통합 테스트 (적당히) — 컴포넌트 간 연결을 본다
    /--------\
   /          \  단위 테스트 (많이)  — 빠르고 정확히 좁힌다
  /------------\

빠르고 좁게 짚는 단위 테스트를 바닥에 많이 두고, 느리지만 현실에 가까운 E2E 테스트를 꼭대기에 조금만 둡니다. 이 비율이 뒤집히면 테스트 묶음이 느려지고 깨지기 쉬워집니다.

7. 점진적 개선: 캠프장 규칙

켄트 벡의 말을 빌리면, 변경을 쉽게 만들고 나서 쉬운 변경을 하라는 원칙이 있습니다. 큰 리팩터링을 한 번에 몰아치지 말고, 손이 닿은 곳을 조금씩 더 낫게 두고 나오는 것입니다. 보이스카우트 규칙처럼, 처음 왔을 때보다 조금 더 깨끗하게 두고 떠나는 것이지요.

마틴 파울러의 "Refactoring"이 가르치는 핵심은 작은 발걸음입니다. 테스트로 안전망을 친 뒤, 한 번에 하나씩, 동작을 바꾸지 않으면서 구조만 손봅니다. 큰 결단의 영웅적 재작성보다, 매일의 작은 정리가 코드베이스를 살립니다.

나쁜 패턴:  6개월 방치 -> 거대한 재작성 -> 새로운 버그 폭탄
좋은 패턴:  매 커밋마다 주변을 조금 정리 -> 부채가 쌓이지 않음

8. 의도적 연습: 그냥 많이 한다고 늘지 않는다

10년을 일해도 늘지 않는 사람이 있고, 3년 만에 깊어지는 사람이 있습니다. 차이는 의도적 연습입니다. 안데르스 에릭손의 연구가 보여 주듯, 단순 반복은 실력을 정체시킵니다. 자기 능력의 가장자리에서, 즉각적인 피드백을 받으며 하는 연습만이 실력을 키웁니다.

8.1 편안한 반복을 경계하라

이미 할 줄 아는 것을 또 하는 것은 휴식이지 연습이 아닙니다. 다음은 의도적 연습의 구체적 형태입니다.

  • 익숙한 언어 말고 사고방식이 다른 언어 하나를 골라 작은 프로젝트를 만들어 봅니다. 객체지향만 했다면 함수형을, 동적 타입만 했다면 정적 타입을.
  • 평소 쓰던 추상화를 한 번은 직접 밑바닥부터 구현해 봅니다. 작은 키-값 저장소, 미니 라우터, 단순 가상 머신 같은 것을.
  • 자신이 작성한 코드를 일주일 뒤 다시 읽고, 무엇이 이해를 방해했는지 적어 봅니다. 그것이 다음에 피할 패턴입니다.

8.2 피드백 루프를 짧게

연습의 효과는 피드백의 속도에 비례합니다. 저장하면 바로 테스트가 돌고, 타입 검사기가 즉시 잘못을 짚어 주는 환경을 만드세요. 피드백이 빠를수록 시도-수정의 회전이 빨라지고, 빠른 회전이 곧 빠른 성장입니다.

9. 코드 읽기: 쓰기보다 먼저 읽어라

개발자는 자기가 쓰는 시간보다 남의 코드를 읽는 시간이 훨씬 깁니다. 그런데도 대부분은 읽기를 따로 연습하지 않습니다. 잘 쓰는 사람은 거의 예외 없이 잘 읽는 사람입니다.

9.1 읽기의 전략

1. 입구를 찾는다   — main, 라우트 핸들러, 진입점부터
2. 큰 덩어리를 본다 — 디렉터리 구조와 모듈 경계를 먼저 파악
3. 한 흐름을 따라간다 — 요청 하나가 끝까지 가는 경로를 추적
4. 질문을 적는다   — "이건 왜 이렇게 했지?"를 기록
5. 가설을 검증한다 — 작은 수정을 하고 테스트로 확인

좋은 오픈소스 한 곳을 골라 한 기능의 흐름을 끝까지 따라가 보세요. 리눅스 커널처럼 거창할 필요는 없습니다. 자신이 매일 쓰는 라이브러리의 핵심 함수 하나로 충분합니다. 잘 쓰인 코드를 충분히 읽으면, 좋은 코드의 감각이 손끝에 배어듭니다.

9.2 읽기에서 배우는 것

남의 코드를 읽으며 우리는 단지 동작을 이해하는 데 그치지 않습니다. 그 사람이 어떤 트레이드오프를 택했는지, 어떤 경우를 미리 대비했는지, 어떤 이름으로 의도를 드러냈는지를 흡수합니다. 글쓰기를 늘리려면 좋은 글을 많이 읽어야 하듯, 코드도 그렇습니다.

10. AI 보조 시대의 역량: 안목과 검증

이제 도구가 코드를 대신 써 주는 시대입니다. 이 변화는 기본기를 무의미하게 만들지 않습니다. 오히려 두 가지 역량을 더 중요하게 만듭니다. 안목과 검증입니다.

10.1 안목: 좋은 답을 알아보는 눈

생성된 코드가 그럴듯해 보이는 것과 옳은 것은 다릅니다. 다음 코드를 봅시다. 도구가 자주 내놓는 종류의 코드입니다.

# 그럴듯하지만 위험한 코드
def get_user(users, user_id):
    return [u for u in users if u["id"] == user_id][0]

동작은 합니다. 그러나 user_id가 없으면 IndexError로 터지고, 중복이 있으면 조용히 첫 번째만 고릅니다. 안목이 있는 사람은 이 빈틈을 즉시 봅니다.

# 의도를 분명히 한 코드
def get_user(users, user_id):
    matches = [u for u in users if u["id"] == user_id]
    if not matches:
        raise KeyError(f"사용자를 찾을 수 없음: {user_id}")
    if len(matches) > 1:
        raise ValueError(f"중복된 사용자 id: {user_id}")
    return matches[0]

생성 도구는 평균적인 코드를 빠르게 줍니다. 평균을 옳은 것으로 끌어올리는 것은 여전히 사람의 안목입니다. 그리고 그 안목은 앞 장들에서 말한 기본기에서 나옵니다.

10.2 검증: 믿지 말고 확인하라

생성된 코드는 자신만만하게 틀립니다. 그래서 검증이 핵심 기술이 됩니다. 테스트로 확인하고, 작은 입력으로 직접 돌려 보고, 경계 조건을 따져 보는 일은 도구가 대신해 주지 않습니다. 도구가 빨라질수록, 그 결과를 책임지고 검증하는 사람의 가치는 오히려 올라갑니다.

도구가 잘하는 것사람이 책임질 것
평범한 코드를 빠르게 생성문제를 옳게 정의하기
익숙한 패턴 적용트레이드오프 판단하기
보일러플레이트 작성결과를 검증하고 책임지기
문법과 API 기억단순함과 안목 유지하기

11. 안티패턴: 잘하는 사람이 피하는 것들

성장은 좋은 것을 더하는 일이기도 하지만, 나쁜 것을 빼는 일이기도 합니다. 다음은 흔하지만 비싼 안티패턴입니다.

  • 복사-붙여넣기 프로그래밍: 이해하지 않고 가져온 코드는 언젠가 반드시 청구서를 보냅니다.
  • 만일을 위한 일반화: 아직 오지 않은 요구를 위해 미리 추상화하면, 대개 그 요구는 오지 않고 복잡함만 남습니다.
  • 영웅적 디버깅: 로그도 테스트도 없이 머릿속으로만 추적하는 자랑. 재현과 관찰이 빠진 디버깅은 운에 기댑니다.
  • 침묵하는 실패: 예외를 삼키고 빈 값을 반환하면, 문제는 사라지지 않고 더 멀리 숨습니다.
  • 똑똑함의 과시: 한 줄로 압축한 영리한 코드는 작성자만 만족시키고 독자를 괴롭힙니다.

이 목록의 공통점은 모두 단기적으로 편하고 장기적으로 비싸다는 것입니다. 잘하는 사람은 그 시차를 압니다.

12. 성장 로드맵: 단계별 지도

마지막으로, 다소 거칠지만 쓸모 있는 단계별 지도를 그려 봅니다. 이 단계는 직급이 아니라 사고의 폭에 관한 것입니다.

1단계: 동작시키기
  - 문법과 도구에 익숙해진다
  - 작은 프로그램을 끝까지 완성해 본다
  - 에러 메시지를 읽는 습관을 들인다

2단계: 잘 동작시키기
  - 자료구조 선택의 차이를 느낀다
  - 디버깅을 추측이 아니라 관찰로 한다
  - 테스트를 스스로 작성한다

3단계: 남이 읽게 하기
  - 이름과 구조로 의도를 드러낸다
  - 작은 단위로 점진적으로 개선한다
  - 코드 리뷰를 주고받으며 배운다

4단계: 시스템으로 생각하기
  - 단순함과 추상화를 의식적으로 다룬다
  - 트레이드오프를 언어로 설명할 수 있다
  - 변경의 비용을 미리 내다본다

5단계: 남을 키우기
  - 자신의 판단 기준을 글과 리뷰로 전한다
  - 팀의 코드베이스를 더 단순하게 만든다
  - 도구와 사람의 역할을 설계한다

이 지도에서 중요한 것은 빠른 통과가 아닙니다. 각 단계에서 충분히 머무르며 그 감각을 손에 익히는 것입니다. 윗 단계로 올라가도 아래 단계의 기본기는 사라지지 않고 토대가 됩니다.

마치며: 오래 가는 사람의 습관

프로그래밍을 잘하는 법에 비밀은 없습니다. 동작하고 읽히고 유지되는 코드를 목표로 삼고, 자료와 시스템에 대한 감각을 기르고, 추측이 아니라 관찰로 디버깅하고, 단순함을 의식적으로 지키고, 사람을 위해 코드를 쓰고, 테스트로 미래를 보호하고, 매일 조금씩 개선하고, 자기 능력의 가장자리에서 의도적으로 연습하고, 남의 코드를 부지런히 읽는 것. 도구가 아무리 빨라져도, 이 습관들이 만들어 내는 안목과 검증의 힘은 사람의 몫으로 남습니다.

결국 잘하는 사람은 비범한 한 방을 가진 사람이 아니라, 평범한 좋은 습관을 오래 지킨 사람입니다. 노빅이 말한 10년은 길게 느껴지지만, 매일의 작은 선택이 쌓이면 그 길은 생각보다 빨리 깊어집니다. 오늘 작성하는 한 함수의 이름부터 조금 더 정직하게 지어 보는 것, 거기서 시작하면 됩니다.

참고 자료