콘텐츠로 건너뛰기
Home » Python 101 – 함수

Python 101 – 함수

프로그래밍에서 함수는 핵심적인 개념이지만, 언어나 상황마다 용어가 다르기도 하고 정의도 모호해서 “함수는 이것이다!”라고 딱 잘라 말하기는 사실 힘듭니다. 위키 백과의 정의만 찾아봐도요.

함수(function), 서브루틴(subroutine), 루틴(routine), 메서드(method), 프로시저(procedure)는 소프트웨어에서 특정 동작을 수행하는 일정 코드 부분을 의미한다.

함수는 대부분의 프로그래밍 언어에서 지원하는 기능으로, 하나의 큰 프로그램을 여러 부분으로 나누어주기 때문에 같은 함수를 여러 상황에서 여러 차례 호출할 수 있으며 일부분을 수정하기 쉽다는 장점을 가진다.

https://ko.wikipedia.org/wiki/함수_(컴퓨터_과학)

가장 넓은 의미에서 함수를 정의하자면 “1개 이상의 동작을 묶어놓은 것”이라고 할 수 있습니다. 즉 일련의 동작을 하나의 단위로 묶어두고, 원하는 만큼 여러 차례 호출하여 사용하는 것이죠. 그렇게해서 전체 코드의 양을 줄일 수 있고, 또 공통된 동작에서 어떤 변경이나 수정이 필요할 때, 소스 코드의 이곳 저곳이 아닌 한 부분만 수정하면 되기 때문에 유지 보수 관점에서도 노력을 아낄 수 있죠.

이미 우리는 파이썬에서 함수를 사용하는 법을 알고 있습니다. 어떤 문자열이나 객체의 내용을 출력하는 print() 나 사용자로부터 문자열을 입력받는 input() 과 같은 동작은 파이썬이 미리 제공하는 함수입니다. 이렇게 별다른 사전 준비 없이 바로 사용할 수 있는, 파이썬이 기본적으로 제공하는 함수를 내장함수(builtin function)라고 합니다. 우리는 내장 함수를 사용하는 것외에 우리가 원하는 동작을 함수로 만들어서 사용할 수도 있습니다. 이런 개념을 내장함수에 상대적인 개념으로 사용자 정의 함수 (user defined function)이라고 합니다.

함수의 호출방법

함수를 호출하는 방법은 함수 이름 뒤에 괄호를 붙이는 것입니다. 이는 가장 기본적인 모양으로input() 과 같은 함수가 이러한 방식으로 작동합니다.

s = input()
print(s)

위 예의 첫번째 줄은 변수 s 에 대한 바인딩 구문입니다. s 라는 이름에 input()의 결과값을 바인딩합니다. 함수의 호출은 그 자체로 하나의 평가식입니다. 함수가 평가되는 동안 s = ... 구문의 작동은 일시적으로 대기합니다. input()은 (실행 조건에 따라 다르지만) 사용자의 키보드 입력을 기다려야 하기 때문에, 컴퓨터 입장에서는 그 실행에 매우 오랜 시간이 걸리는 코드입니다. 사용자가 엔터키를 누를 때까지 s = .. 부분의 코드는 실행되지 못하고 대기합니다.

input() 함수가 실행되는 중간에 사용자가 엔터키를 누르면 지금까지 입력한 키의 문자로 문자열이 생성되고 그 값이 리턴됩니다. 그러면 input() 이라는 함수의 실행이 끝나고, 이 표현식은 리턴값으로 대체되어 다음 차례의 코드가 실행됩니다. 만약 사용자가 “hello”라는 다섯 글자를 입력하고 엔터키를 눌렀다면, 첫 번째 라인의 코드는 s = "hello" 와 같이 평가됩니다.

print() 역시 함수입니다만, 괄호 속에 어떤 값이 들어갔습니다. 함수 호출 시 괄호 속에 값을 넣어줄 수 있고, 이를 인자 혹은 파라미터라고 합니다. 사실 input(), print() 같은 함수들은 상당히 특별한 종류의 함수이고 대부분의 함수들은 입력 값을 받아서 이를 함수 내부에서 처리하고 그 결과를 리턴하는 방식으로 만들어집니다. 모든 함수가 파라미터를 받을 수 있는 것은 아닙니다. 함수를 정의할 때 파라미터를 받도록 명시된 함수들만이 파라미터를 받을 수 있습니다. 최대 몇 개의 파라미터를 받을 수 있고, 최소 몇 개의 파라미터를 필수적으로 전달해야 하는지는 함수가 어떻게 정의되었는지에 달렸습니다.

이러한 함수의 실행 방식과 그 구조에 대해 살펴보았으니, 이제 함수를 정의하는 방법에 대해 살펴보겠습니다.

함수 정의 문법

함수는 기본적으로 def 블럭을 사용하여 정의합니다. def 함수이름(인자...):의 모양으로 함수의 이름과 함수가 받게 되는 인자를 지정할 수 있습니다. (인자와 관련된 내용은 조금 복잡할 수 있으니 뒤에서 정리하겠습니다. ) 함수의 이름을 선언한 라인은 반드시 콜론(:)으로 끝나야 합니다. if, for 구문과 같이 def .. 구문도 콜론으로 끝났으니 들여쓰기로 블럭을 구분하여 함수가 호출되었을 때 실행될 코드를 정의합니다. 들여쓰기가 끝나거나 소스 파일의 끝이 되면 함수의 정의가 끝나는 것으로 해석됩니다. 또 명시적으로 return 구문을 사용하여 함수의 끝을 지정할 수 있습니다. return 을 만나면 함수의 실행을 종료하고, 함수가 호출된 곳으로 실행 흐름이 되돌아가게 됩니다.

파라미터는 함수를 호출할 때와 마찬가지로 괄호 안에 정의하면 됩니다. 함수 내부에서 파라미터들은 이미 정의된 변수처럼 액세스 가능합니다. 파라미터는 함수 내부에서만 통용되는 변수와 같으며, 함수의 실행이 끝나면 파괴된다고 생각하면 됩니다.

간단한 함수를 만들고 사용하는 예를 살펴보겠습니다. 두 개의 값 a, b 를 인자로 받아서 a를 두 배한 후 b와 더해서 반환하는 함수입니다.

def myAdd(a, b):
  return a * 2 + b

myAdd(3, 4)
# => 10

이 예제는 간단한 산술 연산으로 값을 내는 동작을 함수로 만들었습니다만, 조금 더 단계가 많거나 복잡한 동작도 함수로 만드는 것도 가능합니다. 따라서 어떤 코드들을 묶어서 하나의 함수로 만들것인가를 결정하는 것이 사실 사용자 정의 함수를 만드는데 있어서 가장 중요한 포인트가 됩니다. 원칙적으로는 2번 이상 사용되는 코드를 함수로 만들 수 있겠습니다만, 더 일반적으로는 조금 더 거시적인 관점에서 “하나의 동작”으로 분류될 수 있는 코드들을 하나의 함수로 만드는 것이 합리적입니다. 또한 사용자 정의 함수는 입력을 받아서 출력을 내놓는 경우가 일반적이므로 A 라는 입력 값을 B로 변환하는 행위를 하나의 동작으로 보고 함수로 만드는 단위로 보아도 좋습니다.

다른 하나의 예로 우박수를 계산하는 것을 하나의 함수로 볼 수 있습니다. 우박수는 어떤 수가 짝수인 경우에는 그 절반의 값으로, 홀수인 경우에는 그 수를 3배한 후 1을 더한 값으로 변환하는 것입니다. 이 과정을 반복하면 임의의 수는 커졌다 작아졌다를 반복하면서 1이 됩니다. 이 동작을 분류해보면 다음과 같습니다. 함수에 주어진 값, 즉 인자의 이름은 n 이라고 가정합니다.

  1. n == 1 이면 1을 리턴합니다.
  2. n이 짝수이면 2를 나눈 값을 리턴합니다.
  3. n이 1이 아닌 홀수 이면 3 * n + 1 을 리턴합니다.
def hails(n):
  if n == 1:
    return 1
  if n % 2 == 0:
    return n // 2
  return n * 3 + 1

어떤 수에 대한 다음 우박수열의 항을 계산하는 함수를 만들었으니, 아래와 같이 사용할 수 있습니다.

n = int(input())
while n != 1:
  print(n)
  n = hails(n)
print(n)

위 코드를 실행하면 입력한 n 이 1에 다다를 때까지의 우박수열을 출력합니다. 사실 우리는 이 동작 전체도 하나의 함수처럼 만들 수 있습니다. “정수 n을 전달하면 n에 대한 우박수열을 출력하는 함수”가 되는 것입니다. 그리고 이 두 개의 함수로 하나의 완전한 프로그램을 만들 수 있습니다.

# hails.py

def hails(n):
  if n == 1:
    return n
  if n % 2 == 0:
    return n // 2
  return n * 3 + 1

def print_hails(n):
  while n != 1:
    print(n)
    n = hails(n)
  print(n)

if __name__ == "__main__":
  n = int(input("Enter an integer"))
  print_hails(n)

“임의의 정수 n을 입력받아서 n으로부터 시작하여 1까지 도달하는 우박수열을 출력하는 프로그램”은 함수를 사용하지 않고서도 작성할 수 있습니다. 이미 필요한 코드가 위에 다 있으니 직접 작성해 볼 수도 있을 것입니다. 그렇게 작성한 코드를 위 코드와 비교해보면 훨씬 더 복잡하게 느껴집니다. 왜냐하면 전체 코드가 하나의 작업으로 뭉쳐져 있기 때문에 어느 부분이 어떤 과정을 처리하고 있는지를 알아보기가 어렵기 때문입니다. 작은 단위의 작업을 함수로 변환하면 단순히 코드량을 줄이는 것 외에도 작은 함수들을 조합하여 더 큰 기능을 만들어나가는 과정을 보다 수월하게 이끌어 나갈 수 있습니다. 그리고 코드에 오류가 있을 때에도 문제가 되는 부분을 더 쉽게 찾고 고칠 수 있습니다.

지역 변수와 전역 변수

파이썬의 변수에는 스코프(범위)라는 것이 있습니다. 함수 내에서도 새로운 변수를 정의할 수 있습니다. 함수 내에서 정의된 변수는 함수 내부에서만 액세스할 수 있으며, 실행 흐름이 함수를 빠져나가면 즉, 함수의 실행이 종료되면 이러한 지역 변수들은 파괴됩니다. 함수 외부의 지점에서 정의된 변수들은 전역변수이거나 비지역변수(non-local variable)입니다. 특별한 방법을 사용하지 않는 이상, 함수 내부에서 자신의 스코프가 아닌 변수를 변경할 수는 없습니다. 물론 그 “특별한 방법”을 쓰는 것 역시 여러 부작용을 낳기 때문에 자신의 스코프 외부의 값을 변경하는 것은 가급적 사용하지 않는 것이 좋습니다.

함수의 파라미터는 초기값을 함수를 호출한 코드에서 전달해주었다는 차이가 있을 뿐, 함수 내부에서는 일반적인 지역 변수와 아무런 차이가 없습니다. 함수 실행 중간에 다른 값으로 교체될 수 있습니다.

파라미터의 종류

파라미터를 정의할 때에는 기본적으로 두 가지 성격의 파라미터로 정의할 수 있습니다. 하나는 positional 인자라는 것이고 두 번째는 keyword 인자라는 것입니다. positional 인자는 말 그대로 정의해 놓은 순서대로 값을 받게 된다는 것입니다. keyword 는 c=2 와 같이 "name=value" 의 모양으로 값을 넣어줄 수 있는 인자입니다. 이렇게 값을 넣어주는 문법을 keyword parameter 문법이라고 합니다.

사실 keyword 인자는 positional 인자인 동시에 keyword 인자입니다. 즉 디폴트 값을 갖고 있기 때문에 생략 가능한 positional 인자이면서, keyword 인자 문법을 사용했을 때에는 keyword 인자로 작동합니다. 키워드 인자라하더라도 값만 전달하여 호출하는 경우에는 positional 인자와 동일한 방법으로 사용될 수 있습니다.

참고로 함수의 인자를 정의할 때에는 모든 positional 인자를 나열한 후에 뒤에 keyword 인자를 정의해주어야 합니다.

def foo(a, b=3, c=4):
  return a * b + c

foo(1)
# => 7

foo(1, 2)
# b는 여기서는 positional 인자인것처럼 호출됨
# a = 1, b = 2, c = 4
# => 5

foo(1, c=2, b=4)
# keyword 인자들 사이에는 순서가 없음
# a = 1, b = 4, c = 2
# => 6

사실 조금 더 깊게 파고들면 함수의 인자는 네 가지 타입으로 나뉠 수 있습니다. 앞서 keyword 인자는 디폴트값을 갖고 있는 positional 인자이면서 동시에 키워드 인자이고, 이는 호출 할 때 인자를 작성하는 문법에 의해서 구분된다고 했습니다. 파이썬의 확장된 인자 선언 방법을 사용하면 다음과 같은 인자를 모두 명시적으로 구분할 수 있습니다.

  • positional 인자
  • 디폴트 값을 갖고 있는 positional 인자 (b=2 와 같이 키워드 인자 문법을 사용할 수 없음)
  • positional 인자이면서 keyword 인자인 것
  • keyword 전용 인자 (b=2 와 같이 키워드 인자 문법으로만 사용할 수 있음)

명시적으로 디폴트 값을 갖는 positional 인자는 키워드 인자 문법으로 호출할 수 없습니다. 또 키워드 전용 인자는 값만 전달하는 식으로 사용할 수 없게 됩니다. 이러한 구분은 사용의 다소 불편한 것처럼 보이기도 하지만 모호함을 줄이고, 함수를 사용하는 사람이 잘못된 방식으로 함수를 사용할 수 있는 가능성을 줄여줍니다. 확장된 함수 인자 선언에서는 /, * 와 같은 기호를 인자 중간에 쓰게 됩니다. / 앞의 모든 인자는 positional 인자이며, * 뒤의 인자는 키워드 전용 인자입니다.

def foo(a, b=2, /, c=3, *, d=4):
   ....

foo(1, b=2)     # !ERROR : b는 positional 인자이므로 b=2의 키워드 인자 문법으로 호출할 수 없음
foo(1, 2, 3)    # ok
foo(1, 2, c=4)  # ok
foo(1, c=3)     # ok
foo(1, 2, 3, 4) # !ERROR : d는 키워드 전용인자이므로 항상 d=4 의 형식으로 호출해야 함

보너스 – 가변 인자

지금까지의 문법을 사용하여 정의한 함수는 인자의 개수를 고정하거나, 혹은 생략할 수 있는 몇 개의 인자를 지정하여 인자 개수의 최소, 최대 개수가 정해지게 됩니다. 호출할 때에는 그에 맞춘 인자를 사용해야만 합니다. 만약 정수 2개 혹은 3개를 받아서 그 합을 반환하는 함수를 만든다면 다음과 같이 작성할 수 있습니다.

def sum2(a, b, c=0, /):
  return a + b + c

sum2(2, 3)    #=> 5
sum2(2, 3, 4) #=> 9

하지만 이 함수는 2개 혹은 3개의 값에 대해서만 합계를 구할 수가 있습니다. 4개나 5개 숫자의 합을 구하려면 어떻게 해야 할까요? 추가적인 인자를 더 정의할 수 있겠지만, 그보다 훨씬 많은 인자를 사용하려 한다면 어떻게 할까요? 이런 함수들은 내장 함수 중에서도 있습니다. print() 함수만 해도 출력해야 하는 값들을 콤마로 구분하여 원하는만큼 많은 인자를 전달할 수 있습니다. 이렇게 개수가 정해지지 않은 인자를 가변인자라고 합니다. 몇몇 프로그래밍 언어에서는 가변인자를, 다양한 방식으로 지원하는데 파이썬에서도 가변인자를 지정할 수 있습니다.

def foo(a, b, *c, **d):
  ...

위 코드에서 *c 라고 한 부분은 가변 positional 인자입니다. 그리고 앞에 별표를 두 개 붙인 **d 는 가변 키워드인자입니다. 가변 positional인자인 c 는 함수 내부에서 하나의 튜플로 인식됩니다. 그리고 가변 키워드 인자인 d는 함수 내부에서 사전으로 인식됩니다. 따라서 원하는 수를 모두 인자로 전달해서 그 합을 구하는 함수는 아래와 같이 간단하게 작성할 수 있습니다.

def sum2(*a):
  res = 0
  for i in a:
    res += i
  res

sum2(1)          # 1
sum2(1, 2)       # 3
sum2(1, 2, 3)    # 6
sum2(1, 2, 3, 4) # 10
...

인자 앞에 * 을 붙이는 *args 와 같은 문법은 함수를 정의할 때 뿐만 아니라, 함수를 호출할 때에도 사용할 수 있습니다. 함수에 전달할 값들이 리스트나 튜플에 들어있고, 이를 순서대로 전달하려고 할 때 사용할 수 있습니다. 이 문법은 튜플이나 리스트를 분해하여 함수에 전달할 때나 바인딩(대입)구문에 사용하여 코드를 간단하게 만드는데 유용하게 사용될 수 있습니다.

x = (2, 3)

# x의 각 원소를 sum2() 에 전달하려면...
# x가 100개짜리 리스트라면 너무 힘들겠지?
sum2(x[0], x[1])

# x를 분해해서 전달
sum2(*x)
# => 5

보너스 – 타입 어노테이션

파이썬은 기본적으로 동적 타입 언어입니다. 정수나 문자열과 같은 값(객체)은 타입을 가지고 있지 않지만 변수는 타입을 가지지 않습니다. 이 말은 변수의 타입은 변수에 실제로 값이 바인딩 되었을 때 알게 된다는 것입니다. 이러한 동적 타입 언어는 타입과 관련한 부분을 까다롭게 검사하지 않아서 간단한 코드를 빠르게 작성해보는데 도움을 주지만, 잘못된 타입을 사용하면서 생기는 문제를 미리 알아채기가 힘들다는 단점이 있습니다. 아래 짧은 예제에서는 두 개의 정수 값을 더하는데 사용할 것이라 의도하고 add() 라는 함수를 정의했는데, 어쩌다보니 실수로 여기에 정수값과 문자열 값을 각각 넣게 되는 예를 보여줍니다.

def add(x, y):
  return x + y

a = 3
b = '4'
print(add(a, b))

정수와 문자열을 서로 더하는 연산이 정의되지 않았으므로, 이 코드는 실행했을 때 에러가 날 것입니다. 그런데 이러한 실수는 보통 알아채기가 어려운 경우가 많습니다. 특히 파이썬은 변수의 타입에 신경을 쓰지 않으므로 사전에 오류를 검출해볼 수가 없습니다. 변수가 고정된 타입을 갖는 다른 언어들은 대게 이러한 타입 불일치 오류를 컴파일 시점에 검출할 수 있습니다. (컴파일 시점에 검출할 수 있다는 것은 요즘 나오는 개발용 편집기에서 미리 알아낼 수 있다는 의미와 거의 같다고 생각할 수 있습니다.)

이러한 타입 관련 오류를 사전에 검출해 낼 수 있는 것은 전체적인 생산성 향상에 상당히 도움이 되는 일이며, 최근에 개발되는 많은 언어들은 정적 타입 언어들이 갖는 까다로움을 감수하고 타입 오류 검출을 적극적으로 도입하고 있습니다. 그래서 파이썬에서는 타입에 관한 힌트를 코드에 추가할 수 있는 타입 어노테이션이라는 문법을 도입했습니다.

타입 어노테이션은 함수의 인자와 리턴값, 그리고 변수를 선언할 때 변수의 타입을 코드에 명시적으로 표현해두는 것입니다. 물론 이렇게 코드를 작성한다고해서 파이썬 컴파일러가 타입 관련한 최적화를 더 수행하는 것은 아닙니다. (파이썬 컴파일러는 타입 어노테이션 관련 코드는 모두 무시합니다.) 다만, 여러 편집기에서 지원해주는 정적 분석기를 사용해서 이러한 오류를 실행해보기 전에 검출할 수 있게 됩니다.

또한 함수의 타입을 명시하는 것은, 함수를 작성한 의도를 더 분명하게 전달하는데 큰 도움이 되기도 합니다. 아래 스크린샷에서 add() 함수는 파라미터와 리턴타입에 대한 타입 어노테이션을 추가했습니다. 마이크로소프트가 개발한 파이썬 정적 분석기인 pyright 에서는 변수 a, b 의 타입을 그 값으로부터 추론하고, add() 함수에 전달할 수 없는 타입이 발견되었음을 알려주고 있습니다.