Go에서 흐름 제어: break, continue, 그리고 피해야 할 goto 탐구
Emily Parker
Product Engineer · Leapcell

명확성, 단순성 및 동시성에 중점을 둔 Go는 프로그램 흐름을 제어하는 간단한 메커니즘을 제공합니다. 다른 언어에서 발견되는 더 복잡하고 종종 혼란스러운 생성물 중 일부를 피하지만, 루프를 관리하고 실행을 지시하는 필수 문을 여전히 제공합니다. 이 글은 루프 조작을 위한 기본 도구인 break와 continue를 살펴보고, 관용적인 Go에서는 일반적으로 사용이 권장되지 않는 문인 goto에 대해 신중하게 논의할 것입니다.
루프 탐색: break 및 continue
루프는 코드 블록의 반복 실행을 허용하는 프로그래밍의 초석입니다. Go의 for 루프는 매우 다재다능하며, 다른 언어의 for, while, do-while 루프의 목적을 수행합니다. 이러한 루프 내에서 break와 continue는 반복에 세밀한 제어를 제공합니다.
break: 루프 조기 종료
break 문은 가장 안쪽의 for, switch 또는 select 문을 즉시 종료하는 데 사용됩니다. break가 발견되면 제어 흐름은 종료된 생성물 바로 뒤의 문으로 점프합니다.
예제 1: for 루프에서의 기본 break
시퀀스에서 100보다 큰 첫 번째 짝수를 찾으려고 한다고 가정해 봅시다.
package main import "fmt" func main() { fmt.Println("---" + " Using break" + " ---") for i := 1; i <= 200; i++ { if i%2 == 0 && i > 100 { fmt.Printf("Found the first even number > 100: %d\n", i) break // Condition is met, exit the loop } } fmt.Println("Loop finished or broken.") }
이 예에서 i가 102가 되면 if 조건이 참이 되고 "Found..."가 출력되며 break가 루프를 중지합니다. break가 없으면 루프는 200까지 계속될 것이며, 이는 첫 번째 일치만 필요한 경우 비효율적입니다.
예제 2: 레이블이 있는 중첩 루프에서의 break
때때로 중첩된 루프가 있고 내부 루프에서 외부 루프를 중단해야 할 수 있습니다. Go는 레이블을 사용하여 이를 허용합니다. 레이블은 콜론(:)으로 구분된 식별자로, 중단하려는 문 앞에 배치됩니다.
package main import "fmt" func main() { fmt.Println("\n" + "---" + " Using break with labels" + " ---") OuterLoop: // Label for the outer loop for i := 0; i < 3; i++ { for j := 0; j < 3; j++ { fmt.Printf("i: %d, j: %d\n", i, j) if i == 1 && j == 1 { fmt.Println("Breaking out of OuterLoop from inner loop...") break OuterLoop // This breaks the OuterLoop, not just the inner one } } } fmt.Println("After OuterLoop.") }
OuterLoop: 레이블과 break OuterLoop이 없으면 내부 루프가 중단되지만 외부 루프는 반복을 계속합니다(예: i=2 실행). 레이블은 여러 중첩된 생성물에 걸쳐 흐름을 제어하는 수술적인 방법을 제공합니다.
continue: 현재 반복 건너뛰기
continue 문은 루프의 현재 반복의 나머지 부분을 건너뛰고 다음 반복으로 진행하는 데 사용됩니다. 루프 전체를 종료하지는 않습니다.
예제 3: for 루프에서의 기본 continue
1에서 10까지의 홀수만 출력해 봅시다.
package main import "fmt" func main() { fmt.Println("\n" + "---" + " Using continue" + " ---") for i := 1; i <= 10; i++ { if i%2 == 0 { continue // Skip even numbers, go to the next iteration } fmt.Printf("Odd number: %d\n", i) } fmt.Println("Loop completed.") }
여기서 i가 짝수일 때 i%2 == 0이 참이 되면 continue는 즉시 i의 다음 값으로 점프하고( i를 증가시키고 루프 조건을 다시 평가) 짝수에 대한 fmt.Printf 문을 건너뜁니다.
예제 4: 레이블이 있는 continue(덜 일반적이지만 가능)
break와 유사하게 continue도 레이블과 함께 사용할 수 있지만, 덜 자주 보입니다. 레이블과 함께 사용될 때 continue는 레이블이 지정된 루프의 현재 반복의 나머지 부분을 건너뛰고 해당 루프의 다음 반복으로 진행합니다.
package main import "fmt" func main() { fmt.Println("\n" + "---" + " Using continue with labels" + " ---") OuterContinueLoop: for i := 0; i < 3; i++ { for j := 0; j < 3; j++ { if i == 1 && j == 0 { fmt.Printf("Skipping i: %d, j: %d and continuing OuterContinueLoop...\n", i, j) continue OuterContinueLoop // Skips remaining inner loop iterations for i=1, and immediately moves to the next iteration of OuterContinueLoop (i=2) } fmt.Printf("i: %d, j: %d\n", i, j) } } fmt.Println("After OuterContinueLoop.") }
이 예에서 i가 1이고 j가 0일 때 continue OuterContinueLoop 문이 실행됩니다. 이는 현재 i=1에 대한 내부 루프가 중단되고 프로그램이 OuterContinueLoop의 다음 반복인 i=2로 직접 진행됨을 의미합니다.
goto 문: 극도로 주의해서 진행
Go에는 goto 문이 포함되어 있으며, 이를 통해 동일한 함수 내의 레이블이 지정된 문으로 무조건 점프할 수 있습니다. 존재하지만, 현대 프로그래밍 관행, 특히 Go에서는 그 사용이 널리 권장되지 않습니다.
구문:
goto label; // Transfers control to the statement marked by 'label:' // ... label: // statement;
goto가 권장되지 않는 이유는 무엇입니까?
- 복원력 및 가독성 (스파게티 코드):
goto는 코드를 읽고 이해하기 어렵게 만듭니다. 제어 흐름이 임의로 점프하여 실행 경로를 추적하고 프로그램 논리를 이해하기 어려운 "스파게티 코드"를 초래할 수 있습니다. - 유지보수성:
goto를 사용하는 코드는 유지보수, 디버깅 및 리팩터링이 매우 어렵습니다. 코드의 한 부분에서 발생하는 변경은 멀리 떨어진goto점프 때문에 의도하지 않은 결과를 초래할 수 있습니다. - 구조화된 프로그래밍: 현대 프로그래밍 패러다임은
if-else,for,switch및 함수 호출과 같은 생성물을 통해 제어 흐름이 관리되는 구조화된 프로그래밍을 강조합니다. 이러한 생성물은 더 명확하고 예측 가능하며 관리하기 쉬운 코드로 이어집니다.
Go의 goto에 대한 특정 제한 사항:
Go는 goto에 몇 가지 중요한 제한 사항을 적용하여 다른 언어에서 발견되는 몇 가지 일반적인 함정을 방지합니다.
- 블록이 현재 블록과 다른 블록에 정의되어 있거나,
goto문 뒤에 시작되지만goto문을 포함하는 블록 내에 있는 레이블로goto할 수 없습니다. 기본적으로 블록으로 점프하거나 변수 선언을 건너뛸 수 없습니다. - 변수 선언을 건너뛰기 위해
goto할 수 없습니다. goto와 해당 레이블은 동일한 함수 내에 있어야 합니다.
예제 5: Go에서의 goto 사용 가능 사례 (드뭄)
Go에서 goto를 고려할 수 있는 몇 안 되는 시나리오 중 하나는 일련의 작업에서 오류가 발생한 후 리소스를 정리하는 것입니다. 특히 defer가 적합하지 않거나 긴 if err != nil 검사가 번거로워질 때입니다. 이때도 명명된 반환 값과 defer가 종종 선호됩니다.
가상 리소스 할당 시나리오를 고려해 봅시다.
package main import ( "fmt" "os" ) func processFiles(filePaths []string) error { var f1, f2 *os.File var err error // Step 1: Open file 1 f1, err = os.Open(filePaths[0]) if err != nil { fmt.Printf("Error opening %s: %v\n", filePaths[0], err) goto cleanup // Jump to cleanup if error } defer f1.Close() // Defer close for f1 if successfully opened // Step 2: Open file 2 f2, err = os.Open(filePaths[1]) if err != nil { fmt.Printf("Error opening %s: %v\n", filePaths[1], err) goto cleanup // Jump to cleanup if error } defer f2.Close() // Defer close for f2 if successfully opened // Step 3: Perform operations with f1 and f2 fmt.Println("Both files opened successfully. Performing operations...") // ... (actual file processing logic) // In a more complex scenario, imagine more steps here // where errors at any point need a centralized cleanup. cleanup: // This is the label for cleanup fmt.Println("Executing cleanup logic...") // The defer statements above handle closing the files that were successfully opened. // Any other specific cleanup not handled by defer could go here. return err // Return the error encountered (or nil if successful) } func main() { err := processFiles([]string{"non_existent_file1.txt", "non_existent_file2.txt"}) if err != nil { fmt.Println("Processing failed:", err) } err = processFiles([]string{"existing_file.txt", "non_existent_file.txt"}) // Assume existing_file.txt exists for this test if err != nil { fmt.Println("Processing failed:", err) } else { fmt.Println("Processing completed successfully.") } }
참고: Go에서 리소스 정리를 처리하는 관용적인 방법은 종종 defer 문을 통해 이루어집니다. 이전 goto 예제는 defer를 더 효과적으로 사용하거나 함수 흐름을 조기 반환하거나 도우미 함수를 사용하도록 리팩터링할 수 있습니다. goto 버전은 여기서 종종 권장되는 것이 아니라 단순히 볼 수 있는 드문 패턴 중 하나로만 제시됩니다.
goto 예제를 defer 및 조기 반환을 사용하여 리팩터링:
더 관용적인 Go 접근 방식은 다음과 같이 일반적으로 더 명확합니다.
package main import ( "fmt" "os" ) func processFilesIdiomatic(filePaths []string) error { // Open file 1 f1, err := os.Open(filePaths[0]) if err != nil { return fmt.Errorf("error opening %s: %w", filePaths[0], err) } defer f1.Close() // Ensures f1 is closed when function exits // Open file 2 f2, err := os.Open(filePaths[1]) if err != nil { return fmt.Errorf("error opening %s: %w", filePaths[1], err) } defer f2.Close() // Ensures f2 is closed when function exits fmt.Println("Both files opened successfully. Performing operations...") // ... (actual file processing logic) return nil // No error } func main() { fmt.Println("\n" + "---" + " Idiomatic File Processing" + " ---") // For testing, let's create a dummy file dummyFile, _ := os.Create("existing_file.txt") dummyFile.Close() defer os.Remove("existing_file.txt") // Clean up dummy file err := processFilesIdiomatic([]string{"non_existent_file_idiomatic1.txt", "non_existent_file_idiomatic2.txt"}) if err != nil { fmt.Println("Idiomatic processing failed (expected):", err) } err = processFilesIdiomatic([]string{"existing_file.txt", "non_existent_file_idiomatic.txt"}) if err != nil { fmt.Println("Idiomatic processing failed (expected):", err) } else { // This path would only be taken if both files existed fmt.Println("Idiomatic processing completed successfully (unlikely without creating both files).") } }
이 관용적인 버전은 defer가 성공적으로 획득된 각 리소스에 대한 정리를 자연스럽게 처리하고 조기 반환이 임의 점프 없이 제어 흐름을 단순화하기 때문에 일반적으로 선호됩니다.
결론
Go는 강력하고 명확한 제어 흐름 문 집합을 제공합니다. break 및 continue는 루프 반복을 효율적으로 관리하는 데 필수적인 도구이며, 레이블과의 사용은 중첩된 구조에서 정밀한 제어를 제공합니다. goto는 Go에 존재하지만, 읽기 어렵고 유지보수하기 어려운 "스파게티 코드"를 생성할 가능성 때문에 사용이 강력히 권장되지 않습니다. Go의 철학은 단순성과 명시적인 제어를 지향하며, break, continue, 잘 구조화된 if, for, switch 문은 프로그램 흐름을 관리하는 데 거의 항상 충분하고 우수합니다. 명확하고 순차적이며 구조화된 코드를 목표로 하십시오. 미래의 자신과 동료들이 감사하게 될 것입니다.