호출 연기

Defer statement

Defer는 호출을 연기하는 Go의 독특한 구문 중 하나이다. 호출을 연기한다는 것이 무슨 의미인지는 다음 C 코드를 예로 알아보자.

#include <stdio.h>
#include <stdlib.h>

#define BUF_SIZE 512

int main(int argc, char *argv[])
{
	FILE* fp = NULL;
	char* buf = (char*)malloc(BUF_SIZE);

	if (argc != 2)
	{
		printf("empty filename\n");
		free(buf);
		return -1;
	}

	fp = fopen(argv[1], "r");
	if (!fp)
	{
		printf("fopen fail\n");
		free(buf);
		return -1;
	}

	while(fgets(buf, BUF_SIZE, fp))
	{
		printf("%s", buf);
	}

	free(buf);
	fclose(fp);
	return 0;
}

Defer를 알아보기 위해 조금 미련하게 작성하기는 했지만 큰 틀에서 위와 같이 리소스를 열고 닫는 코드는 C/C++, Java 등 언어를 막론하고 많이 접하게 된다. 위 예에서는 메모리를 할당/해제하거나, 파일을 열고 닫는 코드가 이에 해당한다.

메모리 해제에 대한 부담이 없는 언어의 경우라도 열었던 파일을 닫아주는 수고는 필요하다.

소멸자의 호출 시점을 특정할 수 있는 C++이라면 다음과 유사한 코드를 통해 그마나 return 전 여기 저기서 fclose()를 호출해야 하는 부담을 줄일 수는 있다.

class FileCloser
{
public:
	FileCloser(FILE* fp)
	{
		this.fp = fp;
	}
    
	~FileCloser()
	{
		fclose(fp);
	}
    
private:
	FILE* fp;
};

int main(int argc, char *argv[])
{
	FILE* fp = fopen("test.file", "r");
	FileCloser fc(fp);
	//...
}

위 예에서 만든 FileClose 클래스는 template을 이용해 좀 더 일반화할 수 있지만 리소스를 해제하는 방식이 다른 경우라면—fclose() 함수로 닫는 리소스가 아니라면— 개별 작성을 피할 수 없다.

Python은 이를 좀 더 일반화해 with 구문을 제공한다.

with open("test.file") as f:
	# do somthing

이 코드는 with 블록을 벗어날 때 자동으로 f를 닫아주는데, 이를 위해서는 open()으로 연 클래스가 __enter__()__exit()__ 함수를 정의하고 있어야 한다.

다른 방법으로 exception을 지원하는 언어 중에서는 finally 블록에 리소스를 닫는 코드를 작성하는 경우도 있다.

try {
	// open
	// do something
} finally {
	// close
}

Go는 defer 구문을 이용해 이상의 기능을 굉장히 단순하게, 별도의 추가 요구사항 없이도 구현할 수 있다. 절차를 간략히 살펴보면 다음과 같다.

func myFunction() {
	r := openResource()
	defer closeResource(r)

	// do something with r
}

openResource() 함수를 통해 획득한 리소스는 바로 closeResource() 함수 앞에 defer 키워드를 붙여 호출함으로써 닫힘을 예약할 수 있다. 이렇게 예약해 둔 호출은 함수가 종료될 때 자동으로 수행된다. 따라서 함수 중간 중간에 return 구문이 있더라도 따로 리소스를 닫기 위해 신경 쓸 필요가 없어진다.

defer 구문의 호출 순서

간단한 예를 통해 defer 구문의 호출 순서를 확인해 보자.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

import "fmt"

func main() {
    fmt.Println("1st log")
    defer fmt.Println("2nd log")
    fmt.Println("3rd log")
    defer fmt.Println("4th log")
    fmt.Println("5th log")
}
1st log
3rd log
5th log
4th log
2nd log

defer 키워드가 붙지 않은 1st, 3rd, 5th 로그는 순서대로 호출되고, 2nd, 4th 로그는 호출이 연기된 것을 확인할 수 있다. 그런데 이 연기된 두 코드는 역순으로 호출되고 있다. 이처럼 defer는 FILO(First In Last Out) 또는 LIFO(Last In First Out)의 특징을 갖는 스택(stack)에 구문을 저장했다가 호출함을 알 수 있다.

defer 구문의 생명 주기

위 C++의 FileCloser 클래스를 다시 보자. 이 클래스는 지역 변수—fc—를 만들어 소멸자에서 fclose()를 호출하도록 구현한 것이기 때문에 지역 변수의 생명 주기를 갖는다. 즉, 위 예에서와 같이 작성한 경우라면 main() 함수가 종료될 때 호출될 것이다. 하지만 다음과 같은 많은 경우에는 이상 동작을 하게 된다.

int main(int argc, char *argv[])
{
	// case 1
	FILE* fp = fopen("test.file", "r");
	if (true)
	{
		FileCloser fc(fp);
	}
	// do something with fp

	// case 2
	if (true)
	{
		FILE* fp = fopen("test.file", "r");
		FileCloser fc(fp);
	}
	// do something with fp

	// case 3
	FILE* fp = fopen("test.file", "r");
	{ FileCloser fc(fp); }
	// do something with fp
}

지역 변수의 생명 주기는 닫는 중괄호(})를 만나면 끝나기 때문에 fc가 언제 소멸되는지 주의할 필요가 있다.

반면 defer 구문의 생명 주기는 함수와 함께 한다.

package main

import "fmt"

func main() {
	fmt.Println("1st log")
	if true {
		defer fmt.Println("2nd log")
	}
	fmt.Println("3rd log")
}

즉, 위와 같이 지역 변수의 생명 주기를 if 블록 내부로 제한하더라도 실행 결과는 다음과 같이 defer 구문이 main() 함수가 종료될 때 호출된다.

1st log
3rd log
2nd log

그렇다면 defer 구문이 main() 함수 종료 이전에 수행되도록 강제하려면 어떻게 해야 할까? Go가 익명 함수를 지원함을 상기하자.

package main

import "fmt"

func main() {
	fmt.Println("1st log")
	func() {
		defer fmt.Println("2nd log")
	}()
	fmt.Println("3rd log")
}