함수: 코드 재사용과 구조화의 핵심

Functions in Python

파이썬에서 함수는 코드 재사용성과 유지보수성을 높이는 핵심 도구이다. 이 글에서는 함수의 정의와 호출, 다양한 매개변수 유형(기본 매개변수, 키워드 인자, *args, **kwargs), 그리고 변수의 스코프와 LEGB 규칙까지 체계적으로 다룬다.

Engineering
Python
저자

Kwangmin Kim

공개

2023년 07월 02일

1 개요

이 글은 파이썬의 함수(Function)를 체계적으로 다룬다. 다음과 같은 흐름으로 구성되어 있다:

  1. 함수의 개념: 함수란 무엇이며, 왜 필요한가
  2. 함수의 구성요소: 함수명, 매개변수, 본문, 반환값
  3. 다양한 매개변수 유형: 기본 매개변수, 키워드 인자, *args, **kwargs
  4. 변수의 스코프: 지역변수, 전역변수, LEGB 규칙

2 함수란 무엇인가

2.1 함수의 정의

Wikipedia는 함수(function)를 다음과 같이 정의한다:

“특정 작업을 수행하는 프로그램 명령 시퀀스를 하나의 단위로 패키지한 것”

이 정의에서 핵심은 두 가지다. 첫째, 특정 작업을 수행한다는 것이고, 둘째, 하나의 단위로 패키지된다는 것이다.

“하나의 단위로 패키지된다”는 표현을 실무적으로 풀어보면 이렇다. 어떤 작업을 수행하는 10줄짜리 코드가 있다고 가정하자. 이 코드가 프로그램 여러 곳에서 필요하다면, 함수 없이는 그 10줄을 필요한 곳마다 복사해서 붙여넣어야 한다. 이렇게 되면 나중에 그 로직을 수정할 때 복사한 곳을 모두 찾아서 바꿔야 하는데, 하나라도 놓치면 버그가 발생한다.

함수는 이 10줄을 하나의 이름이 붙은 단위로 묶어두고, 필요한 곳에서 그 이름을 호출하는 방식으로 문제를 해결한다. 수정이 필요하면 함수 내부 한 곳만 바꾸면 된다.

2.2 함수가 필요한 이유

함수의 장점을 실제 코드 수준에서 풀어보면 다음과 같다.

2.2.1 코드 재사용성

같은 로직을 반복해서 작성하지 않아도 된다. 데이터 과학 작업에서 전처리 로직을 함수로 만들어두면 훈련 데이터, 검증 데이터, 테스트 데이터에 동일한 함수를 적용할 수 있다. 코드 중복이 없으므로 한 곳을 수정하면 모든 적용 지점에 반영된다.

2.2.2 가독성 향상

함수명이 의도를 드러내기 때문에 코드 자체가 문서가 된다. clean_missing_values(df)라는 호출 한 줄은 그 내부의 10줄짜리 처리 코드보다 훨씬 빠르게 의도를 전달한다.

2.2.3 유지보수성 향상

로직이 한 곳에 집중되어 있으므로 수정 범위가 명확하다. 결측값 처리 전략을 바꿔야 한다면 clean_missing_values 함수 내부만 수정하면 된다.

3 함수의 구성요소

Python에서 함수는 4가지 요소로 구성된다.

def sum(num1, num2):
    print(f"첫 번째 숫자: {num1}")
    print(f"두 번째 숫자: {num2}")
    return num1 + num2

3.1 함수명 (Function Name)

함수 바깥에서 이 함수를 호출하기 위한 식별자이다. 위 예시에서는 sum이 함수명이다. 좋은 함수명은 함수가 무엇을 하는지 이름만 봐도 알 수 있어야 한다. sum, calculate_tax, load_dataset처럼 동사 또는 동사+명사 조합이 일반적이다.

3.2 매개변수 (Parameters)

함수가 실행되는 데 필요한 입력값의 자리표시자이다. 위 예시에서는 num1, num2가 매개변수이다. 매개변수는 함수 정의 시점에는 실제 값이 없고, 함수가 호출될 때 전달된 인자(argument)와 연결되어 값을 갖게 된다.

3.3 본문 (Function Body)

함수가 호출되었을 때 실제로 실행되는 명령문의 집합이다. 들여쓰기(indent)로 함수 바깥 코드와 구분된다. Python에서 들여쓰기는 단순한 스타일 규칙이 아니라 코드 블록의 소속을 결정하는 문법적 요소이다.

3.4 반환값 (Return Value)

함수 실행이 완료된 후 호출한 곳으로 돌려주는 결과값이다. return 키워드 뒤에 반환할 표현식을 작성한다. 반환값이 없는 함수는 return을 생략할 수 있으며, 이 경우 Python은 자동으로 None을 반환한다.

4 함수 정의와 호출

4.1 함수 정의 문법

Python에서 함수를 정의하는 기본 문법은 다음과 같다:

def 함수명(매개변수1, 매개변수2, ...):
    # 본문
    return 반환값

4.1.1 핵심 규칙 3가지

규칙 1: 정의만으로는 코드가 실행되지 않는다

def 블록을 작성하는 것은 함수의 존재를 등록하는 것이지 실행하는 것이 아니다. 함수 내부의 코드는 호출이 발생하는 순간에만 실행된다.

규칙 2: 같은 이름으로 다시 정의하면 이전 함수가 사라진다

Python은 함수 이름을 일반 변수와 동일하게 취급한다. 따라서 같은 이름으로 함수를 두 번 정의하면 두 번째 정의가 첫 번째를 완전히 덮어쓴다.

규칙 3: return을 만나는 순간 함수는 종료된다

return 이후의 코드는 실행되지 않는다. 이것은 조기 반환(early return) 패턴으로 활용할 수 있다:

def divide(a, b):
    if b == 0:
        return None   # 조기 반환: 이 이후 코드는 실행되지 않는다
    return a / b      # b가 0이 아닌 경우에만 도달

4.2 함수 호출

def sum(num1, num2):
    print(f"첫 번째 숫자: {num1}")
    print(f"두 번째 숫자: {num2}")
    return num1 + num2

result = sum(1, 5)   # 이 시점에 비로소 함수 내부가 실행된다
print(result)        # 6

4.3 Parameter vs Argument

이 두 용어는 일상적으로 혼용되지만, 엄밀히는 다른 것을 가리킨다.

용어 시점 의미
매개변수(Parameter) 함수 정의 자리표시자(placeholder)
인자(Argument) 함수 호출 실제로 전달하는 값
def sum(num1, num2):   # num1, num2가 매개변수(Parameter)
    return num1 + num2

result = sum(1, 5)     # 1, 5가 인자(Argument)

5 다양한 매개변수 유형

Python은 매개변수 설계를 위한 여러 가지 도구를 제공한다.
- 기본 매개변수: param=value, 함수 정의 시 기본값이 지정되는 매개변수 - 키워드 인자: func(param=value), 함수 호출 시 매개변수 이름을 명시해야하는 인자 - *args: 위치 인자 가변 길이 매개변수, 인자가 튜플로 전달되는 매개변수 - **kwargs: 키워드 인자 가변 길이 매개변수, 인자가 딕셔너리로 전달되는 매개변수

5.1 기본 매개변수 (Default Parameter)

함수를 호출할 때 해당 인자를 전달하지 않으면 자동으로 사용되는 값을 미리 지정하는 기능이다.

def study(name, language="python"): # name은 일반 매개변수, language는 기본 매개변수
    print(f"{name} 님은 {language} 수업 중입니다.")

study("철수")              # 철수 님은 python 수업 중입니다.
study("영희", "java")      # 영희 님은 java 수업 중입니다.
  • name: 일반 매개변수
  • language: 기본 매개변수
  • 주의: 기본 매개변수는 일반 매개변수 뒤에 위치해야 한다.

5.1.1 가변 객체를 기본값으로 사용할 때의 함정

리스트나 딕셔너리 같은 가변 객체(mutable object)를 기본값으로 지정하면 예상치 못한 동작이 발생한다:

# 위험한 패턴
def append_item(item, container=[]):
    container.append(item)
    return container

print(append_item(1))   # [1]
print(append_item(2))   # [1, 2] <- 의도와 다른 결과!

해결책은 기본값을 None으로 지정하고 함수 내부에서 새로운 객체를 생성하는 것이다:

# 올바른 패턴
def append_item(item, container=None):
    if container is None:
        container = []
    container.append(item)
    return container

5.2 키워드 인자 (Keyword Argument)

  • 호출 시 매개변수 이름을 명시해서 값을 전달하는 방식이다.
  • 만약 매개변수가 수십개라면 수십개의 매개변수의 순서를 숙지하여 인자를 순서대로 전달하는 것은 매우 어렵다.
  • 키워드 인자를 사용하면 매개변수의 순서에 관계없이 원하는 매개변수에 값을 전달할 수 있다.
  • 인자의 순서에 관계없이 원하는 매개변수에 값을 전달할 수 있다.
  • 단, 키워드 인자를 사용하지 않으면 반드시 매개변수의 위치대로 인자를 전달해야 한다.
def greet(name, greeting):
    print(greeting, name)

greet(name="Alice", greeting="Hello")   # 순서 무관
greet(greeting="Hi", name="Bob")        # 순서를 바꿔도 동일하게 동작

키워드 인자의 장점:

  • 명시성: train_model(learning_rate=0.001, epochs=100, batch_size=32)train_model(0.001, 100, 32)보다 훨씬 명확하다.
  • 안전성: 함수 시그니처가 변경되어 매개변수 순서가 바뀌더라도 키워드 인자로 호출하는 코드는 영향을 받지 않는다.
    • 함수 시그니처(signature): 함수의 외부 인터페이스를 정의하는 정보의 집합이다. 구성 요소는 함수명, 매개변수의 이름, 순서, 개수, 각 매개변수의 타입 힌트 (있는 경우), 반환 타입 (있는 경우) 등을 포함한다. 함수 시그니처는 함수의 호출 방식과 계약을 정의하는 역할을 한다.
    • 예시: def train_model(learning_rate, epochs, batch_size)def train_model(epochs, learning_rate, batch_size)로 변경되어도 train_model(learning_rate=0.001, epochs=100, batch_size=32)는 여전히 정상 동작한다.

5.3 가변 인자 리스트 (*args)

  • 몇 개의 인자가 전달될지 미리 알 수 없는 경우, *args를 사용한다.
  • 매개변수 이름 앞에 *를 붙이면 전달된 위치 인자를 모두 튜플로 묶어서 받는다.
  • args 이름은 관례일 뿐, 변경 가능하다. *numbers, *items 등으로도 사용할 수 있다.
  • 마찬가지로 **kwargs** 뒤 이름은 자유롭게 변경 가능하다
  • *args**kwargs는 함께 사용할 때 반드시 *args**kwargs보다 앞에 위치해야 한다.
  • *args**kwargs는 일반 매개변수와 기본 매개변수 뒤에 위치해야 한다.
  • 따라서, *args**kwargs는 함수 정의에서 가장 뒤에 위치해야 한다.
  • *args는 모든 데이터 타입을 받을 수 있다.
# 위치 인자 가변 길이 매개변수 예시
def add_numbers(*args):
    result = 0
    for num in args:
        result += num
    return result

add_numbers(1)                    # args = (1,)
add_numbers(1, 10, -10, 5)        # args = (1, 10, -10, 5)
add_numbers(1, 2, 3, 10, 100, 50) # args = (1, 2, 3, 10, 100, 50)

# `*args`는 모든 데이터 타입을 받을 수 있다
def show(*args):
    for item in args:
        print(type(item), item)

show(1, 3.14, "hello", True, [1, 2], {"a": 1})

# 출력 결과
# <class 'int'>   1
# <class 'float'> 3.14
# <class 'str'>   hello
# <class 'bool'>  True
# <class 'list'>  [1, 2]
# <class 'dict'>  {'a': 1}
  • 단, 타입 힌트로 제한하고 싶다면 명시할 수 있다:
  • 이것은 힌트(hint) 일 뿐이고, 런타임에 강제되지는 않는다.
  • 실제로 강제하려면 함수 내부에서 직접 타입 검사를 해야 한다
def add_numbers(*args: int) -> int:   # int만 받겠다는 의도 표현
    return sum(args)

# 런타임에 강제하려면 직접 타입 검사
def add_numbers(*args):
    for arg in args:
        if not isinstance(arg, int):
            raise TypeError(f"int만 허용됩니다. 전달된 타입: {type(arg)}")
    return sum(args)    

5.4 키워드 가변 인자 리스트 (**kwargs)

  • *args위치 인자를 가변적으로 받는다면, **kwargs키워드 인자를 가변적으로 받는다.
  • 매개변수 이름 앞에 **를 붙이면 전달된 키워드 인자를 모두 딕셔너리로 묶어서 받는다.
  • kwargs 이름은 관례일 뿐, 변경 가능하다. **options, **params 등으로도 사용할 수 있다.
def print_kv(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_kv(name="alpha", age=10)
# 출력 결과: key: value 쌍으로 출력된다.
# name: alpha
# age: 10

5.5 매개변수 순서 규칙

  • 일반 매개변수, *args, **kwargs를 함께 사용할 때는 반드시 다음 순서를 지켜야 한다:
  • 순서 규칙 위반시 SyntaxError: invalid syntax 에러 발생
일반 매개변수 → 기본 매개변수 → *args → **kwargs
def display_info(name, *args, **kwargs):
    print("Name:", name)
    print("Known Languages:", ', '.join(args))
    for key, value in kwargs.items():
        print(f"{key}: {value}")

display_info("Alice", "python", "java", age=30, country="KR")
# *args가 일반 매개변수보다 앞에 오는 경우
def func(*args, name):       # 문법 오류는 아니지만 동작이 달라진다
    print(name, args)

func("python", "java", name="Alice")  # name은 반드시 키워드 인자로만 전달 가능
func("Alice", "python", "java")       # TypeError: func() missing 1 required keyword-only argument: 'name'
  • *args가 일반 매개변수보다 앞에 오면, name은 키워드 전용 매개변수가 되어야만 syntaxError가 발생하지 않는다.

  • 따라서 func("Alice", "python", "java")처럼 *args에 “Alice”, “python”, “java”가 할당되고 name엔 어떤 인자고 들어가지 않는 꼴이 되어 TypeError가 발생한다. name은 반드시 name="Alice"처럼 키워드 인자로 전달해야 한다.

  • 함수 정의시 매개변수 유형

유형 문법 내부 자료형 위치
일반 매개변수 param 단일 값 가장 앞
기본 매개변수 param=value 단일 값 일반 뒤
가변 인자 *args tuple 기본 뒤
키워드 가변 인자 **kwargs dict 가장 뒤

6 변수의 스코프

6.1 스코프(Scope)란?

  • 스코프는 변수가 유효하게 접근될 수 있는 코드의 범위를 의미한다.
  • 즉, 변수가 메모리에 생성되는 시점과 삭제되는 시점과 관련된 개념이다.
  • Python은 변수를 참조할 때 변수 이름을 찾는 순서인 LEGB 규칙을 따른다:
    • Local(지역) → Enclosing(둘러싸는) → Global(전역) → Built-in(내장)
  • 안쪽에서 바깥쪽으로 4단계를 순서대로 탐색한다. 만약, 변수 이름을 global 변수에서 못찾으면 built-in까지 탐색한다.
x = "Global"                    # 3. Global — 모듈 최상위

def outer():
    x = "Enclosing"             # 2. Enclosing — 중첩 함수 구조에서 바깥 함수 범위안에 있는 변수

    def inner():
        x = "Local"             # 1. Local — 현재 함수 (가장 먼저 탐색)
        print(x)                # → "Local" 출력

    inner()

outer()
  • 핵심은 안에서 바깥은 보이지만, 바깥에서 안은 보이지 않는다는 것이다.
# 함수 안에서 만든 변수를 바깥에서 접근하려는 시도
def func():
    var = "variable"
    print(var)   # 함수 내부에서는 정상 동작

print(var)   # NameError: name 'var' is not defined
# 함수 바깥에서 만든 변수를 함수 안에서 접근하려는 시도
var = "variable"
def func():
    print(var)   # 정상 동작 (전역변수 읽기 가능)

6.2 지역변수 vs 전역변수

구분 지역변수 (Local) 전역변수 (Global)
정의 위치 함수 내부 함수 바깥(모듈 최상위)
생명주기 함수 호출 시 생성, 종료 시 소멸 프로그램 실행 내내 존재
접근 범위 해당 함수 내부에서만 어디서든 읽기 가능

6.3 global 키워드

함수 내부에서 전역변수를 수정해야 하는 경우, global 키워드를 사용한다:

count = 0

def increment():
    global count   # 이 변수는 전역변수임을 명시
    count += 1

increment()
increment()
print(count)   # 2

# 만약 global 예약어 없이 사용하면 다음과 같은 에러가 발생한다.
count = 0

def increment():
    count += 1   # global 없이 수정 시도

increment()
# UnboundLocalError: local variable 'count' referenced before assignment

# 단, 수정을 안 할경우 에러 발생하지 않음
count = 0

def show():
    print(count)   # 읽기만 → 에러 없음. LEGB 규칙으로 Global에서 발견

show()   # 0 출력
  • 그러나 global 키워드는 신중하게 사용해야 한다.
  • 전역변수를 여러 함수에서 수정하기 시작하면 어느 함수가 언제 값을 바꿨는지 추적하기 어려워진다.
# 전역변수를 여러 함수가 수정하는 문제적 패턴

total = 0
discount = 0

def add_item(price):
    global total
    total += price          # total을 수정

def apply_discount(rate):
    global total, discount
    discount = total * rate
    total -= discount       # total을 또 수정

def apply_tax(rate):
    global total
    total *= (1 + rate)     # total을 또 수정

# 실행
add_item(10000)
add_item(5000)
apply_discount(0.1)
apply_tax(0.1)

print(total)   # 이 값이 어떻게 계산된 건지 코드를 전부 따라가야만 알 수 있다

##############################################################################################################################
# 이 코드의 문제는 total이 언제, 어떤 함수에 의해 얼마나 바뀌었는지 실행 흐름을 전부 머릿속에서 추적해야만 최종값을 이해할 수 있다는 것이다. 
# 반환값 방식으로 개선하면:
##############################################################################################################################

# 각 함수가 새로운 값을 반환 — 상태가 명시적으로 흘러간다

def add_item(total, price):
    return total + price

def apply_discount(total, rate):
    discount = total * rate
    return total - discount

def apply_tax(total, rate):
    return total * (1 + rate)

# 실행 — total의 변화가 코드 한 줄 한 줄에서 눈에 보인다
total = 0
total = add_item(total, 10000)   # 10000
total = add_item(total, 5000)    # 15000
total = apply_discount(total, 0.1)  # 13500
total = apply_tax(total, 0.1)       # 14850

print(total)   # 14850

# 실무에서는 함수가 새로운 값을 **반환**하고 호출자가 그 값을 관리하는 방식을 선호한다: 

7 정리

이 글에서 다룬 내용을 연결해서 보면 하나의 흐름으로 이어진다:

개념 역할
함수 반복되는 코드를 재사용 가능한 단위로 묶는다
모듈 관련 함수들을 파일 단위로 조직화한다
패키지 관련 모듈들을 디렉토리 계층으로 묶는다
라이브러리 특정 목적을 위한 패키지들의 완결된 집합이다
가상환경 프로젝트별로 독립된 라이브러리 환경을 보장한다

이 계층 구조 전체가 하나의 목표를 향한다: 작성한 코드를 안전하게 재사용하고, 팀원과 공유하고, 다른 환경에서도 동일하게 실행되도록 만드는 것이다.

Subscribe

Enjoy this blog? Get notified of new posts by email: