본문 바로가기
프로그래밍/Go

Go defer

by JLearn 2026. 7. 22.
반응형

Go의 defer는 현재 함수를 종료하기 직전에 실행할 함수 호출을 등록하는 문법입니다.

파일 닫기, 잠금 해제, 타이머 중지, context 취소처럼 함수가 끝날 때 반드시 수행해야 하는 정리 작업에 주로 사용합니다. 정리 코드를 자원을 확보한 위치 바로 아래에 작성할 수 있어, 중간에 return이 여러 번 있어도 빠뜨릴 가능성을 줄여 줍니다.

defer 함수호출()

defer에 등록된 함수는 즉시 실행되지 않습니다. 현재 함수가 return을 실행하거나 함수 본문의 끝에 도달할 때, 또는 panic으로 함수 호출 스택이 정리될 때 실행됩니다.

defer는 프로그램이나 고루틴이 끝날 때 실행되는 기능이 아니라, defer를 작성한 현재 함수가 끝날 때 실행되는 기능입니다.


defer의 기본 동작

다음 예제에서 fmt.Println("함수 종료")defer를 만나는 시점에 실행되지 않고 run 함수가 끝나기 직전에 실행됩니다.

package main

import "fmt"

func run() {
    fmt.Println("함수 시작")
    defer fmt.Println("함수 종료")
    fmt.Println("작업 실행")
}

func main() {
    run()
}

실행 결과:

함수 시작
작업 실행
함수 종료

실행 흐름은 다음과 같습니다.

  1. 함수 시작을 출력합니다.
  2. defer fmt.Println("함수 종료") 호출을 나중에 실행하도록 등록합니다.
  3. 작업 실행을 출력합니다.
  4. run 함수가 끝나기 직전에 등록된 함수를 실행합니다.

main 함수가 종료될 때까지 기다리는 것이 아니라 run 함수의 종료 시점에 실행된다는 점이 중요합니다.

func first() {
    defer fmt.Println("first 종료")
    fmt.Println("first 실행")
}

func main() {
    first()
    fmt.Println("main 계속 실행")
}

실행 결과:

first 실행
first 종료
main 계속 실행

first에 등록된 deferfirst가 반환되기 전에 실행되므로 main 계속 실행보다 먼저 출력됩니다.


여러 defer의 실행 순서

한 함수에서 여러 defer를 등록하면 나중에 등록한 함수부터 실행됩니다. 이를 LIFO(Last In, First Out), 즉 후입선출 순서라고 합니다.

package main

import "fmt"

func main() {
    defer fmt.Println("첫 번째 등록")
    defer fmt.Println("두 번째 등록")
    defer fmt.Println("세 번째 등록")

    fmt.Println("본문 실행")
}

실행 결과:

본문 실행
세 번째 등록
두 번째 등록
첫 번째 등록

자원을 여러 개 순서대로 확보했다면 반대 순서로 해제해야 하는 경우가 많습니다. defer의 후입선출 동작은 이러한 정리 순서와 잘 맞습니다.

func process() {
    resourceA := acquireA()
    defer resourceA.Close()

    resourceB := acquireB()
    defer resourceB.Close()

    // 함수 종료 시 resourceB.Close()가 먼저 실행되고
    // resourceA.Close()가 나중에 실행됩니다.
}

인수는 defer를 만나는 시점에 평가된다

defer로 등록한 함수 자체는 나중에 실행되지만, 함수에 전달하는 인수는 defer 문을 실행하는 시점에 평가되어 저장됩니다.

package main

import "fmt"

func main() {
    count := 1
    defer fmt.Println("저장된 값:", count)

    count = 10
    fmt.Println("현재 값:", count)
}

실행 결과:

현재 값: 10
저장된 값: 1

fmt.Println은 함수 종료 시점에 실행되지만, 인수 countdefer가 등록될 때의 값인 1로 저장됩니다.

현재 함수가 끝나는 시점의 값을 사용하고 싶다면 익명 함수가 해당 변수를 참조하도록 작성할 수 있습니다.

package main

import "fmt"

func main() {
    count := 1

    defer func() {
        fmt.Println("종료 시점의 값:", count)
    }()

    count = 10
    fmt.Println("현재 값:", count)
}

실행 결과:

현재 값: 10
종료 시점의 값: 10

두 코드의 차이는 다음과 같습니다.

작성 방식 사용하는 값
defer fmt.Println(count) defer 등록 시점의 count
defer func() { fmt.Println(count) }() 익명 함수 실행 시점에 참조되는 count

return과 defer의 실행 순서

함수가 값을 반환할 때는 반환값이 먼저 결정되고, 등록된 defer가 실행된 다음, 호출자에게 실제로 반환됩니다.

개념적인 순서는 다음과 같습니다.

반환값 결정
→ defer 함수 실행
→ 호출자에게 반환

일반 반환값에서는 다음처럼 확인할 수 있습니다.

package main

import "fmt"

func calculate() int {
    result := 10

    defer func() {
        result = 20
        fmt.Println("defer 내부:", result)
    }()

    return result
}

func main() {
    fmt.Println("반환값:", calculate())
}

실행 결과:

defer 내부: 20
반환값: 10

return result를 실행할 때 반환할 값 10이 먼저 복사되어 결정됩니다. 그 뒤 defer가 지역 변수 result20으로 바꾸더라도 이미 결정된 반환값은 바뀌지 않습니다.

반면 이름이 지정된 반환값은 defer 함수에서 읽거나 변경할 수 있습니다.

package main

import "fmt"

func calculate() (result int) {
    defer func() {
        result++
    }()

    return 10
}

func main() {
    fmt.Println(calculate())
}

실행 결과:

11

return 10이 이름 있는 반환 변수 result10을 대입한 뒤 deferresult11로 변경하고, 최종적으로 11이 반환됩니다.

이 기능은 반환 오류에 문맥을 추가하는 용도로 사용할 수 있습니다.

func loadConfig(path string) (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("설정 파일 %q 처리 실패: %w", path, err)
        }
    }()

    _, err = os.ReadFile(path)
    return err
}

다만 단순한 오류 처리에서는 반환 지점에서 직접 fmt.Errorf를 사용하는 방식이 더 읽기 쉬울 수 있습니다. 이름 있는 반환값을 defer에서 변경할 때는 흐름이 숨겨질 수 있으므로 목적이 분명한 경우에 사용하는 것이 좋습니다.


파일과 같은 자원 정리

defer의 대표적인 사용 사례는 파일을 연 직후 Close를 등록하는 것입니다.

package main

import (
    "fmt"
    "os"
)

func readFile(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("파일 열기 실패: %w", err)
    }
    defer file.Close()

    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("파일 읽기 실패: %w", err)
    }

    return data, nil
}

func main() {
    data, err := readFile("message.txt")
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println(string(data))
}

이 예제는 defer 위치를 보여 주기 위한 것이지만, 이미 연 file을 사용하려면 io.ReadAll(file)처럼 읽는 것이 자연스럽습니다.

func readFile(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("파일 열기 실패: %w", err)
    }
    defer file.Close()

    data, err := io.ReadAll(file)
    if err != nil {
        return nil, fmt.Errorf("파일 읽기 실패: %w", err)
    }

    return data, nil
}

파일을 성공적으로 연 뒤 바로 defer file.Close()를 작성하면 이후 어느 위치에서 반환하더라도 파일 닫기가 실행됩니다.

다만 쓰기 작업에서는 Close가 반환하는 오류도 의미가 있을 수 있습니다. 단순히 defer file.Close()만 작성하면 닫기 과정에서 발생한 오류를 확인하지 못합니다.

func writeFile(path string, data []byte) (err error) {
    file, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("파일 생성 실패: %w", err)
    }

    defer func() {
        if closeErr := file.Close(); closeErr != nil && err == nil {
            err = fmt.Errorf("파일 닫기 실패: %w", closeErr)
        }
    }()

    if _, err = file.Write(data); err != nil {
        return fmt.Errorf("파일 쓰기 실패: %w", err)
    }

    return nil
}

이 방식은 기존 쓰기 오류가 없을 때만 Close 오류를 반환값으로 반영합니다.


mutex 잠금 해제

공유 데이터를 보호하기 위해 sync.Mutex를 잠갔다면 잠금 직후 Unlockdefer로 등록할 수 있습니다.

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()

    return c.value
}

func main() {
    counter := &Counter{}
    counter.Increment()
    fmt.Println(counter.Value())
}

Lockdefer Unlock을 가까이 두면 함수 중간에 반환문이 추가되어도 잠금 해제를 빠뜨리지 않게 됩니다.

func (c *Counter) AddIfPositive(value int) bool {
    c.mu.Lock()
    defer c.mu.Unlock()

    if value <= 0 {
        return false
    }

    c.value += value
    return true
}

value <= 0으로 조기 반환하더라도 Unlock은 실행됩니다.


context의 cancel 함수 호출

context.WithCancel, context.WithTimeout, context.WithDeadline은 취소 함수도 함께 반환합니다. 이 취소 함수는 관련 자원을 해제하고 자식 컨텍스트에 취소를 전달하므로, 더 이상 필요하지 않은 시점에 호출해야 합니다.

func requestData(parent context.Context) error {
    ctx, cancel := context.WithTimeout(parent, 3*time.Second)
    defer cancel()

    return callExternalAPI(ctx)
}

callExternalAPI가 정상 종료되거나 오류로 조기 반환되어도 cancel()이 실행됩니다.

타임아웃이 실제로 발생하면 컨텍스트 자체가 취소되지만, 작업이 타임아웃보다 먼저 끝날 수도 있으므로 defer cancel()을 등록하는 것이 일반적입니다.


반복문 안에서 defer를 사용할 때

defer는 블록이 아니라 함수 종료 시점에 실행됩니다. 따라서 긴 반복문 안에서 파일을 열고 defer file.Close()를 등록하면 각 반복이 끝날 때 닫히는 것이 아니라 현재 함수가 끝날 때 한꺼번에 닫힙니다.

func processFiles(paths []string) error {
    for _, path := range paths {
        file, err := os.Open(path)
        if err != nil {
            return err
        }
        defer file.Close()

        // 파일 처리
    }

    return nil
}

파일 수가 많거나 함수 실행 시간이 길다면 열린 파일이 계속 쌓여 파일 디스크립터를 과도하게 사용할 수 있습니다.

각 반복의 작업을 별도 함수로 분리하면 해당 함수가 반복마다 종료되므로 defer도 반복마다 실행됩니다.

func processFiles(paths []string) error {
    for _, path := range paths {
        if err := processFile(path); err != nil {
            return err
        }
    }

    return nil
}

func processFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("파일 열기 실패: %w", err)
    }
    defer file.Close()

    _, err = io.Copy(io.Discard, file)
    if err != nil {
        return fmt.Errorf("파일 처리 실패: %w", err)
    }

    return nil
}

반복마다 즉시 닫는 것이 더 명확하다면 defer 대신 직접 Close를 호출할 수도 있습니다.

func processFiles(paths []string) error {
    for _, path := range paths {
        file, err := os.Open(path)
        if err != nil {
            return err
        }

        _, processErr := io.Copy(io.Discard, file)
        closeErr := file.Close()

        if processErr != nil {
            return processErr
        }
        if closeErr != nil {
            return closeErr
        }
    }

    return nil
}

반복문 안의 defer가 항상 잘못된 것은 아닙니다. 반복 횟수가 작고 자원이 함수 종료까지 유지되어도 문제가 없다면 사용할 수 있습니다. 중요한 것은 실행 시점이 반복문의 끝이 아니라 함수의 끝이라는 점을 알고 사용하는 것입니다.


조건문 안에서 defer를 등록할 때

defer는 프로그램이 해당 문장을 실제로 실행했을 때만 등록됩니다.

func run(debug bool) {
    if debug {
        defer fmt.Println("디버그 작업 종료")
    }

    fmt.Println("작업 실행")
}

debugtrue이면 defer가 등록되고, false이면 등록되지 않습니다.

또한 defer가 등록되기 전에 함수가 반환되면 해당 호출은 존재하지 않습니다.

func run(valid bool) {
    if !valid {
        return
    }

    defer fmt.Println("종료")
    fmt.Println("실행")
}

validfalse이면 defer 문에 도달하지 않으므로 종료는 출력되지 않습니다.


panic이 발생한 경우

현재 고루틴에서 panic이 발생하면 일반적인 실행 흐름은 중단되지만, 호출 스택이 정리되는 동안 이미 등록된 defer 함수는 실행됩니다.

package main

import "fmt"

func run() {
    defer fmt.Println("자원 정리")

    fmt.Println("작업 시작")
    panic("처리할 수 없는 오류")
}

func main() {
    run()
}

출력은 다음과 같은 순서로 나타납니다.

작업 시작
자원 정리
panic: 처리할 수 없는 오류

이 동작으로 인해 오류나 panic이 발생하더라도 잠금 해제, 파일 닫기와 같은 정리 작업을 수행할 수 있습니다.

recoverpanic을 복구하는 내장 함수이며, 패닉이 진행 중인 같은 고루틴의 지연 함수에서 직접 호출할 때 의미가 있습니다.

package main

import "fmt"

func run() {
    defer func() {
        if recovered := recover(); recovered != nil {
            fmt.Println("복구:", recovered)
        }
    }()

    panic("작업 실패")
}

func main() {
    run()
    fmt.Println("main 계속 실행")
}

실행 결과:

복구: 작업 실패
main 계속 실행

recover는 일반적인 오류 처리를 대신하는 기능이 아닙니다. 예상 가능한 실패는 error로 반환하는 것이 Go의 일반적인 방식이며, panicrecover는 정상적으로 계속 처리하기 어려운 비정상 상황이나 패키지 내부의 제한된 경계에서 신중하게 사용해야 합니다.

또한 한 고루틴에서 발생한 panic을 다른 고루틴의 recover로 복구할 수 없습니다.


defer에 익명 함수를 사용할 때

추가 로직이 필요하거나 종료 시점의 지역 변수를 참조해야 한다면 익명 함수를 등록할 수 있습니다.

func run() {
    startedAt := time.Now()

    defer func() {
        elapsed := time.Since(startedAt)
        fmt.Println("실행 시간:", elapsed)
    }()

    performTask()
}

익명 함수를 사용할 때 마지막의 ()를 빠뜨리지 않아야 합니다.

defer func() {
    fmt.Println("종료")
}()

defer 뒤에는 함수 값 자체가 아니라 함수 또는 메서드 호출식이 와야 합니다.

잘못된 예:

defer func() {
    fmt.Println("종료")
}

올바른 예:

defer func() {
    fmt.Println("종료")
}()

defer를 사용할 때 판단 기준

defer를 이해할 때는 문법을 따로 외우기보다 어느 함수에 등록되었는지, 언제 등록되었는지, 무엇이 미리 평가되는지를 순서대로 확인하는 것이 좋습니다.

확인할 내용 판단 기준
실행 시점 defer가 작성된 현재 함수가 반환되기 직전
등록 여부 실행 흐름이 실제로 defer 문에 도달했을 때만 등록
여러 호출의 순서 마지막에 등록한 호출부터 실행
전달 인수 defer를 등록하는 시점에 평가
익명 함수가 참조하는 변수 익명 함수가 실행되는 시점에 값을 읽음
반복문 내부 사용 반복이 끝날 때가 아니라 바깥 함수가 끝날 때 실행
강제 프로세스 종료 os.Exit가 호출되면 실행되지 않음

현재 함수의 종료 시점을 먼저 확인한다

defer는 자신을 둘러싼 가장 가까운 중괄호가 아니라, 자신이 작성된 함수를 기준으로 실행됩니다.

func run() {
    if true {
        defer fmt.Println("run 종료")
    }

    fmt.Println("if 이후 작업")
}

실행 결과:

if 이후 작업
run 종료

if 블록이 끝날 때 실행되는 것이 아니라 run 함수가 끝날 때 실행됩니다. 반복문 안에서도 같은 기준이 적용됩니다.

defer 문에 실제로 도달했는지 확인한다

defer는 프로그램 실행이 해당 문장에 도달했을 때 등록됩니다. 조건이 맞지 않거나 먼저 반환하면 등록되지 않습니다.

func run(valid bool) {
    if !valid {
        return
    }

    defer fmt.Println("정리")
    fmt.Println("작업")
}

validfalse이면 defer 문까지 실행되지 않으므로 정리 함수도 등록되지 않습니다.

따라서 자원 정리용 defer는 자원 획득에 성공한 직후 등록하는 것이 안전합니다.

file, err := os.Open(path)
if err != nil {
    return err
}
defer file.Close()

파일 열기에 실패하기 전에 defer file.Close()를 작성할 수는 없으며, 성공 후 다른 반환문보다 앞에 두어야 합니다.

등록 시점의 값과 종료 시점의 값을 구분한다

다음 두 코드는 비슷해 보이지만 값을 읽는 시점이 다릅니다.

value := 1
defer fmt.Println(value)
value = 10

fmt.Println에 전달할 valuedefer 등록 시점에 평가되므로 1이 출력됩니다.

value := 1
defer func() {
    fmt.Println(value)
}()
value = 10

익명 함수는 실행 시점에 value를 참조하므로 10이 출력됩니다.

종료 시점의 상태가 필요한지, 등록 시점의 값을 기록하려는 것인지에 따라 작성 방식을 선택해야 합니다.

반복문에서는 자원 유지 범위를 확인한다

반복문 안의 defer는 각 반복이 끝날 때 실행되지 않습니다.

func process(paths []string) error {
    for _, path := range paths {
        file, err := os.Open(path)
        if err != nil {
            return err
        }
        defer file.Close()
    }

    return nil
}

위 코드에서는 process 함수가 끝날 때까지 열린 파일들이 유지됩니다. 파일이 많으면 파일 디스크립터가 누적될 수 있습니다.

이 경우에는 한 번의 반복 작업을 별도 함수로 분리하는 방식이 더 적절합니다.

func process(paths []string) error {
    for _, path := range paths {
        if err := processOne(path); err != nil {
            return err
        }
    }
    return nil
}

func processOne(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close()

    _, err = io.Copy(io.Discard, file)
    return err
}

processOne이 반복마다 종료되므로 파일도 반복마다 닫힙니다.

정상 반환과 프로세스 종료를 구분한다

일반적인 return에서는 이미 등록된 defer가 실행됩니다.

func run() error {
    defer fmt.Println("정리")
    return errors.New("작업 실패")
}

반면 os.Exit는 호출 스택을 정상적으로 반환하지 않고 프로세스를 즉시 종료하므로 defer가 실행되지 않습니다.

func main() {
    defer fmt.Println("실행되지 않음")
    os.Exit(1)
}

종료 코드와 정리 작업을 함께 사용해야 한다면 실제 작업을 별도 함수로 분리합니다.

func run() error {
    defer fmt.Println("정리 작업")
    return errors.New("실행 실패")
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

run이 반환되는 과정에서 정리 작업이 먼저 실행되고, 그 이후 main에서 os.Exit(1)이 호출됩니다.

defer 사용 여부는 안전성과 범위를 기준으로 결정한다

파일 닫기, 잠금 해제, 컨텍스트 취소처럼 함수 종료 시 반드시 수행해야 하는 작업에는 defer가 잘 맞습니다.

mutex.Lock()
defer mutex.Unlock()

반대로 자원을 함수 종료보다 훨씬 일찍 해제해야 하거나, 반복문에서 매번 즉시 닫아야 하는 경우에는 직접 정리하거나 작은 함수로 분리하는 편이 낫습니다.

성능만을 이유로 일반적인 defer 사용을 피할 필요는 없습니다. 다만 매우 빈번하게 실행되는 경로에서는 추측보다 벤치마크로 확인하고, 반복문 안에서는 성능보다 자원이 얼마나 오래 유지되는지를 먼저 살펴보는 것이 중요합니다.

전체 예제

다음 예제는 함수 종료 시점, 인수 평가 시점, 후입선출 순서, 이름 있는 반환값 변경을 함께 보여 줍니다.

package main

import "fmt"

func execute() (result int) {
    value := 1

    defer fmt.Println("등록 시점의 value:", value)

    defer func() {
        fmt.Println("종료 시점의 value:", value)
    }()

    defer func() {
        result += 5
        fmt.Println("반환값 변경:", result)
    }()

    value = 10
    result = 20

    fmt.Println("함수 본문:", value, result)
    return
}

func main() {
    result := execute()
    fmt.Println("최종 반환값:", result)
}

실행 결과:

함수 본문: 10 20
반환값 변경: 25
종료 시점의 value: 10
등록 시점의 value: 1
최종 반환값: 25

결과를 통해 다음 내용을 확인할 수 있습니다.

  • defer 함수는 현재 함수가 반환되기 직전에 실행됩니다.
  • 여러 defer는 나중에 등록한 순서부터 실행됩니다.
  • 일반 함수 호출의 인수는 등록 시점에 평가됩니다.
  • 익명 함수가 참조한 변수는 실행 시점의 값을 읽을 수 있습니다.
  • 지연 함수는 이름 있는 반환값을 최종 반환 전에 변경할 수 있습니다.

정리

defer는 현재 함수가 끝날 때 실행할 함수 호출을 등록합니다.

resource, err := acquireResource()
if err != nil {
    return err
}
defer resource.Close()

핵심 동작은 다음과 같습니다.

구분 동작
실행 시점 현재 함수가 반환되기 직전
인수 평가 defer 문을 실행하는 시점
여러 호출 순서 나중에 등록한 호출부터 실행
조기 반환 이미 등록된 defer 실행
panic 스택 정리 과정에서 등록된 defer 실행
이름 있는 반환값 지연 함수에서 읽고 변경 가능
반복문 반복 종료가 아니라 함수 종료 시 실행
os.Exit 지연 함수가 실행되지 않음

파일, 네트워크 연결, 잠금, 컨텍스트처럼 사용 후 정리가 필요한 자원은 확보에 성공한 직후 defer를 등록하는 방식이 일반적입니다.

file, err := os.Open(path)
if err != nil {
    return err
}
defer file.Close()

이렇게 작성하면 정리 작업이 자원 획득 코드 가까이에 위치하고, 이후 반환 경로가 늘어나더라도 정리를 빠뜨릴 가능성을 줄일 수 있습니다.


참고 자료

반응형

'프로그래밍 > Go' 카테고리의 다른 글

Go Timer와 Ticker  (0) 2026.07.24
Go panic과 recover  (0) 2026.07.23
Go context  (0) 2026.07.21
Go select, timeout, close, context  (0) 2026.07.20
Goroutine과 channel  (1) 2026.07.18

댓글