PH pullh
입문 블로그 / 함수는 언제 나누는 게 좋을까
코드 기초 5분 Beginner

함수는 언제 나누는 게 좋을까

함수를 잘게 쪼개야 한다는 말은 자주 듣지만, 초보자는 어디서 끊어야 할지 감이 잘 안 온다. 가장 실용적인 기준만 골랐다.

함수 나누기 커버 이미지

처음에는 함수를 만드는 것 자체가 어색합니다. 그런데 어느 순간부터는 코드가 길어져서, 어디서부터 잘라야 할지가 더 어려운 문제로 바뀝니다. 함수 분리는 멋있어 보이기 위한 작업이 아니라, 몇 주 뒤에 이 파일을 다시 열었을 때 덜 헤매기 위한 작업입니다.

그래서 기준도 단순합니다. 지금 이 코드 묶음에 이름을 붙일 수 있는가, 그 이름이 실제로 하는 일과 맞는가. 이 글에서는 그 기준을 실제 코드 하나를 고쳐 가면서 확인하고, 반대로 너무 열심히 나눴을 때 어떤 대가를 치르는지도 같이 봅니다.

이름을 붙일 수 있으면 나눌 수 있습니다

여러 줄의 코드 묶음을 보고 "이 부분은 입력 검증, 이 부분은 계산, 이 부분은 출력 만들기"처럼 이름을 붙일 수 있다면, 그 묶음은 함수로 나눌 후보입니다. 이름이 떠오른다는 것은 그 묶음이 하나의 이야기로 요약된다는 뜻이고, 요약이 되는 덩어리는 본문에서 빼내도 흐름이 끊기지 않습니다.

반대로 이름이 잘 안 붙는다면 성급히 나누지 않아도 됩니다. 아직 로직이 뭘 하는지 스스로도 정리되지 않은 상태에서 이름을 억지로 지으면, 나중에 그 이름이 거짓말을 하기 시작합니다. 이름이 거짓말을 하는 함수는 없는 함수보다 나쁩니다. 읽는 사람이 이름만 믿고 안을 안 들여다보기 때문입니다.

이름을 붙이는 연습은 주석을 쓰려는 순간에 가장 잘 됩니다. 코드 위에 # 쿠폰 코드에 따라 할인액 계산이라고 적고 싶어졌다면, 그 주석 문장이 거의 그대로 함수 이름이 됩니다. 주석은 시간이 지나면 코드와 어긋나지만 함수 이름은 호출부에서 계속 눈에 띄기 때문에 훨씬 오래 정확합니다.

한 함수 안에 네 가지 일이 섞여 있을 때

아래는 주문 목록과 쿠폰 코드를 받아 영수증 문자열을 만드는 함수입니다. 동작은 합니다. 다만 검증, 항목 계산, 할인 계산, 문자열 포매팅이 한 몸에 들어 있어서 어느 한 군데를 고치려면 전체를 다시 읽어야 합니다.

BEFORE

def make_receipt(orders, coupon):
    if not orders:
        return "주문 없음"
    total = 0
    lines = []
    for o in orders:
        name = o.get("name")
        qty = o.get("qty")
        price = o.get("price")
        if not name or qty is None or price is None:
            return "항목 정보가 부족합니다"
        if qty <= 0:
            return "수량이 올바르지 않습니다"
        if price < 0:
            return "가격이 올바르지 않습니다"
        amount = qty * price
        total += amount
        lines.append(f"{name} x{qty}  {amount}원")
    if coupon == "TEN":
        discount = int(total * 0.10)
    elif coupon == "FIVE":
        discount = int(total * 0.05)
    else:
        discount = 0
    lines.append(f"합계 {total}원")
    lines.append(f"할인 {discount}원")
    lines.append(f"결제 {total - discount}원")
    return "\n".join(lines)

30줄 남짓이지만, 읽는 사람은 검증 규칙과 할인 규칙과 출력 형식을 동시에 머리에 올려야 합니다.

이 코드를 처음 쓴 사람에게는 아무 문제가 없습니다. 위에서 아래로 한 번 써 내려간 순서 그대로이기 때문입니다. 문제는 두 번째로 읽는 사람에게 생깁니다. 두 번째 독자는 보통 전체를 이해하러 오는 게 아니라 한 가지를 고치러 옵니다. 그런데 이 함수는 고칠 지점만 골라 읽는 것을 허락하지 않습니다.

이 함수가 불편한 이유는 길이가 아닙니다. 할인율 하나를 바꾸려는 사람도 위쪽 검증 코드를 지나가야 하고, 출력 형식만 바꾸려는 사람도 계산 로직을 스크롤로 넘겨야 한다는 점이 불편합니다. 게다가 검증이 실패하면 문자열을 반환하는데, 이 문자열은 영수증과 똑같은 타입이라 호출한 쪽에서 성공과 실패를 구분하기 어렵습니다.

어디서 끊었고 왜 거기서 끊었는지

AFTER

def validate_orders(orders):
    for o in orders:
        if not o.get("name"):
            return "항목 이름이 비어 있습니다"
        if o.get("qty") is None or o["qty"] <= 0:
            return "수량이 올바르지 않습니다"
        if o.get("price") is None or o["price"] < 0:
            return "가격이 올바르지 않습니다"
    return None


def line_items(orders):
    return [(o["name"], o["qty"], o["qty"] * o["price"]) for o in orders]


def discount_for(total, coupon):
    rates = {"TEN": 0.10, "FIVE": 0.05}
    return int(total * rates.get(coupon, 0))


def format_receipt(items, total, discount):
    lines = [f"{name} x{qty}  {amount}원" for name, qty, amount in items]
    lines.append(f"합계 {total}원")
    lines.append(f"할인 {discount}원")
    lines.append(f"결제 {total - discount}원")
    return "\n".join(lines)


def make_receipt(orders, coupon):
    if not orders:
        return "주문 없음"
    error = validate_orders(orders)
    if error:
        return error
    items = line_items(orders)
    total = sum(amount for _, _, amount in items)
    return format_receipt(items, total, discount_for(total, coupon))

전체 줄 수는 오히려 늘었지만, 고칠 곳을 찾는 시간은 줄어듭니다.

경계를 이렇게 잡은 이유는 세 가지입니다. 첫째, 각 함수가 값을 하나만 돌려줍니다. discount_for는 할인액 하나, line_items는 항목 목록 하나입니다. 반환값이 하나면 호출하는 쪽에서 "이 함수를 부르면 무엇을 얻는지"를 한 문장으로 말할 수 있습니다.

둘째, 부수효과가 없습니다. 네 함수 모두 인자로 받은 값만 보고 새 값을 만들어 돌려줍니다. 전역 변수를 고치거나 화면에 출력하지 않으므로, 테스트할 때는 값을 넣고 나온 값을 비교하기만 하면 됩니다. 작은 테스트를 붙이는 방법은 아주 작은 케이스부터 테스트를 시작하는 법에서 더 자세히 다룹니다.

셋째, 이름이 하는 일과 정확히 맞습니다. validate_orders는 검증만 하고 계산은 하지 않습니다. 만약 이 함수가 검증하면서 총액도 같이 계산해 두었다면, 이름은 거짓말이 되고 다음 사람은 그걸 알아채기 전까지 헤맵니다. 함수 안에서 다루는 값이 어떤 상태를 표현하는지 헷갈린다면 변수는 결국 상태에 관한 이야기를 같이 읽어 보면 도움이 됩니다.

참고로 make_receipt는 여전히 검증 실패 시 문자열을 반환합니다. 이 부분까지 손대려면 예외를 던지거나 결과 객체를 돌려주는 식으로 설계를 바꿔야 하는데, 그건 "함수를 나눈다"와는 다른 결정입니다. 한 번에 한 가지만 바꾸는 편이 안전합니다.

나누기 전에 확인하는 신호

매번 고민하기 번거롭다면, 아래 신호 중 두 개 이상이 겹칠 때만 손을 대도 충분합니다. 하나만 걸릴 때는 대개 그냥 두는 편이 낫습니다. 신호 하나로 움직이면 나중에 다시 합치는 일이 잦아집니다.

  • 같은 모양의 코드가 두 번 이상 나옵니다. 세 번째가 나오기 전에 나눕니다.
  • 코드 위에 주석으로 소제목을 달고 싶어집니다. 그 주석이 함수 이름 후보입니다.
  • 함수 안에서 다루는 대상이 바뀝니다. 주문 항목을 보다가 갑자기 출력 문자열을 다루기 시작하는 지점이 경계입니다.
  • 들여쓰기가 세 단계 이상 깊어집니다. 안쪽 블록을 통째로 함수로 빼면 대체로 한 단계가 사라집니다.
  • 지역 변수가 열 개 가까이 쌓입니다. 서로 관련 없는 변수들이 한 스코프에 있다는 뜻입니다.

과분리의 대가

여기까지만 읽고 "그럼 최대한 잘게 나누자"로 가면 반대쪽 벽에 부딪힙니다. 함수 분리는 공짜가 아니라 비용이 있는 선택입니다.

첫 번째 비용은 읽기 흐름입니다. 함수 하나가 두 줄짜리로 쪼개져 열 개쯤 늘어서 있으면, 전체 동작을 파악하려고 정의를 따라 위아래로 계속 점프하게 됩니다. 한 화면에서 끝나던 이야기가 열 번의 이동으로 바뀌면, 읽는 사람은 각 함수 이름을 기억하는 데 집중력을 쓰느라 정작 로직을 놓칩니다. 코드 리뷰에서 "이 함수 뭐 하는 건지 보려면 파일 세 군데를 봐야 한다"는 말이 나오면 대체로 이 상태입니다.

두 번째 비용은 인자 수입니다. 억지로 쪼갠 함수는 원래 한 스코프에 있던 변수들을 인자로 다시 받아야 합니다. 인자가 다섯 개, 여섯 개로 늘어나면 호출부는 순서를 틀리기 쉬워지고, 한쪽을 고칠 때 다른 쪽 시그니처도 같이 고쳐야 합니다. 나누기 전보다 결합도가 오히려 올라간 셈입니다. 인자가 계속 늘어난다면 그건 "더 나누라"는 신호가 아니라 "여기는 경계가 아니다"라는 신호입니다.

세 번째 비용은 이름입니다. 나눌 곳이 아닌 데를 나누면 붙일 이름이 없어서 process_data2, handle_step_b, do_work_inner 같은 것들이 생깁니다. 이런 이름은 아무 정보도 주지 않으면서 호출부만 늘립니다. 이름이 안 나온다는 사실 자체가 그 자리에 경계가 없다는 증거로 보면 됩니다.

과분리인지 아닌지는 호출부를 보면 대체로 알 수 있습니다. 잘 나뉜 코드는 최상위 함수만 읽어도 무슨 일이 순서대로 벌어지는지 짐작이 갑니다. 과하게 나뉜 코드는 최상위 함수를 읽어도 아무것도 알 수 없어서, 결국 모든 하위 함수를 열어 봐야 합니다. 위 예시의 make_receipt는 검증하고, 항목을 만들고, 합계를 내고, 형식을 맞춘다는 것이 그 자리에서 읽힙니다. 그 정도가 기준선입니다.

마지막으로, 순차적으로 한 번만 읽히면 되는 코드는 길어도 괜찮습니다. 한 번 돌리고 버리는 데이터 정리 스크립트, 설정을 순서대로 세팅하는 초기화 코드처럼 위에서 아래로 한 번만 따라가면 되는 코드는 나누는 순간 "순서"라는 정보가 흩어집니다. 재사용할 일도 없고 테스트할 일도 없다면 그냥 쭉 이어 쓰는 편이 정직합니다.

알아두면 좋은 점

나눌지 말지 애매할 때는 함수 이름을 먼저 소리 내어 말해 봅니다. "주문 목록을 받아서 할인액을 돌려준다"처럼 한 문장으로 말해지면 나눌 자리입니다. "일단 앞부분을 처리하고 뒷부분도 좀 하고"처럼 접속사가 들어가야 말이 된다면 아직 아닙니다.

자주 하는 실수

동작을 바꾸는 작업과 나누는 작업을 한 커밋에서 같이 합니다. 그러면 결과가 달라졌을 때 원인이 분리 때문인지 로직 수정 때문인지 알 수 없습니다. 먼저 동작을 그대로 둔 채 나누고, 그게 잘 돌아가는 것을 확인한 다음에 로직을 고칩니다.

오늘 바로 해 볼 것

  • 지금 작업 중인 파일에서 가장 긴 함수를 하나 엽니다.
  • 그 안에서 주석을 달고 싶어지는 지점 두 곳에 표시합니다.
  • 그중 반환값이 하나로 정리되는 쪽만 함수로 빼냅니다. 두 곳 다 하지 않습니다.
  • 빼낸 함수를 직접 호출해 값이 그대로 나오는지 한 번 확인합니다.
  • 인자가 넷을 넘어가면 되돌립니다. 그 자리는 경계가 아니었습니다.

파이썬 함수의 인자 기본값, 반환값 여러 개 다루기 같은 문법을 더 확인하고 싶다면 파이썬 함수 가이드를 참고하면 됩니다.

함수를 나누는 목적은 코드를 짧게 만드는 것이 아니라, 다음에 고칠 사람이 읽어야 할 범위를 좁히는 것입니다.

코드 기초

Next Read

배열, 리스트, 딕셔너리를 처음 배울 때 헷갈리는 지점

문법을 외우는 것보다 언제 어떤 모양의 데이터를 쓰는지 이해하면 자료구조 입문이 훨씬 쉬워진다.