VB.NET 프로시저: ByVal·ByRef, AndAlso, Try/Catch 실행으로 익히기

VB.NET Sub·Function, ByVal·ByRef를 .NET 10 출력으로 확인. Select Case 범위, 반복문, And가 낸 NullReferenceException, Catch When, AndAlso 실측.

시리즈: VB.NET 강좌 2/6 (초급). 1강을 마쳤거나 간단한 VB 는 쓸 줄 알지만, "읽히는 대로 동작하는" 프로시저와 제어문을 쓰고 싶은 사람을 위한 글입니다. 다 읽으면 어떤 인수든 ByVal·ByRef 가 무엇을 하는지 예측하고, 알맞은 분기·반복문을 고르고, 잡아야 할 예외만 잡는 Try/Catch 를 쓸 수 있습니다.

사내 프로그램을 고치다가 "ByVal 을 ByRef 로 바꾸니 되던데 이유는 모르겠다"는 경험이 있거나, Nothing 검사를 하는 줄에서 NullReferenceException 이 나서 당황한 적이 있다면 이 글이 답입니다. 둘 다 당연해 보이지만 실은 다른 규칙에서 나옵니다. ByVal 이 무엇을 복사하는지, 그리고 VB 의 And 가 양쪽을 반드시 평가한다는 사실입니다. 아래 예제는 모두 .NET 10 에서 빌드·실행하고 출력을 그대로 붙였으며, 마지막에 연산자를 잘못 골랐을 때의 비용을 잽니다.

환경: .NET SDK 10.0.403, 런타임 10.0.14, Visual Basic 17.13, .vbproj 에 <OptionStrict>On</OptionStrict>(1강 참고), Apple Silicon Mac (M1 Pro).

Sub 와 Function, 그리고 빠뜨린 Return

Sub 는 일만 하고 값을 돌려주지 않습니다. Function 은 Return 으로 값을 돌려주고, 오래된 코드에서는 함수 이름에 대입해서 돌려주기도 합니다.

Function Square(x As Integer) As Integer
    Return x * x
End Function

Function Cube(x As Integer) As Integer
    Cube = x * x * x          ' 옛 방식, 지금도 유효
End Function

Square(4) 와 Cube(3) 은 16 27 을 출력했습니다. 문제는 Return 에 닿지 않는 경로가 있어도 컴파일된다는 점입니다.

Function Discount(price As Decimal) As Decimal
    If price > 100D Then
        Return price * 0.9D
    End If
End Function

Function Label(code As Integer) As String
    Select Case code
        Case 1 : Return "one"
    End Select
End Function
Program.vb(8,5): warning BC42353: 'Discount' 함수가 일부 코드 경로에서만 값을 반환합니다. 'Return' 문이 있는지 확인하세요.
Program.vb(14,5): warning BC42105: 'Label' 함수가 일부 코드 경로에서만 값을 반환합니다. 이 결과를 사용하면 런타임에 null 참조 예외가 발생할 수 있습니다.

경고뿐이고 빌드는 성공합니다. 실행하면 Discount(50D) 는 0, Label(2) Is Nothing 은 True 입니다. 100 이하 금액이 전부 0으로 정산되는, 운영까지 살아남는 종류의 버그입니다. 프로젝트에 <WarningsAsErrors>BC42353;BC42105</WarningsAsErrors> 를 넣으면 같은 두 줄이 error BC42353, error BC42105 로 바뀌어 빌드가 멈추는 것을 확인했습니다.

ByVal 과 ByRef: 실제로 복사되는 것

VB 인수는 기본이 ByVal 입니다. 규칙은 짧습니다. ByVal 은 변수의 내용을 복사한다. 값 형식(Integer, Date, Structure)이면 값 자체, 참조 형식(List, 배열, Class)이면 참조가 복사되므로 호출한 쪽과 프로시저가 같은 객체를 가리킵니다. 모든 경우를 파일 하나로 확인합니다.

Imports System
Imports System.Collections.Generic

Module Program
    Sub BumpVal(ByVal n As Integer)
        n += 1
    End Sub
    Sub BumpRef(ByRef n As Integer)
        n += 1
    End Sub
    Sub AddItemVal(ByVal list As List(Of String))
        list.Add("added")                               ' 호출한 쪽 객체를 바꾼다
        list = New List(Of String) From {"replaced"}    ' 지역 복사본만 다시 건다
    End Sub
    Sub AddItemRef(ByRef list As List(Of String))
        list.Add("added")
        list = New List(Of String) From {"replaced"}
    End Sub
    Sub FillArray(ByVal arr As Integer())
        arr(0) = 99
    End Sub

    Sub Main()
        Dim n = 10
        BumpVal(n) : Console.WriteLine($"after BumpVal: {n}")
        BumpRef(n) : Console.WriteLine($"after BumpRef: {n}")
        BumpRef((n)) : Console.WriteLine($"after BumpRef((n)): {n}")

        Dim names As New List(Of String) From {"a"}
        AddItemVal(names) : Console.WriteLine($"ByVal list: {String.Join(",", names)}")
        names = New List(Of String) From {"a"}
        AddItemRef(names) : Console.WriteLine($"ByRef list: {String.Join(",", names)}")

        Dim arr = {1, 2, 3}
        FillArray(arr) : Console.WriteLine($"array after ByVal: {String.Join(",", arr)}")
    End Sub
End Module
after BumpVal: 10
after BumpRef: 11
after BumpRef((n)): 11
ByVal list: a,added
ByRef list: replaced
array after ByVal: 99,2,3

다시 볼 줄이 세 개입니다.

  • ByVal list: a,added: ByVal 로 받았는데도 호출한 쪽 리스트가 바뀌었습니다. "ByVal 이면 호출한 쪽은 안전하다"는 말은 값 형식에서만 맞습니다.
  • ByRef list: replaced: ByRef 에서는 다시 대입한 것이 호출한 쪽 변수까지 바꿔서, "added" 를 받았던 원래 리스트는 호출한 쪽에서 더 이상 보이지 않습니다.
  • BumpRef((n)): 인수를 괄호로 한 번 더 감싸면 식이 되어 ByRef 매개변수에도 임시 복사본이 넘어갑니다. n 은 11 그대로. 코드 정리하다 괄호를 "다듬었을" 때 생기는 단골 사고입니다.
두 줄짜리 그림. 호출한 쪽 변수 names 는 List 객체를 가리키고, 그 객체에는 결국 a 와 added 가 들어간다. ByVal 매개변수 list 는 참조의 복사본이라 같은 List 를 가리키므로 list.Add 는 호출한 쪽 객체를 바꾼다. list = New List 문은 매개변수만 replaced 를 가진 새 List 로 바꿔 걸고, 프로시저가 끝나면 사라진다. ByRef 라면 호출한 쪽 변수 자체가 바뀌어 replaced 가 보인다.
참조 형식을 ByVal 로 넘기면 복사되는 건 객체가 아니라 참조다. 두 변수가 같은 List 를 가리키므로 Add 는 호출한 쪽에 보이지만, 매개변수에 다시 대입한 것은 보이지 않는다.

받은 리스트를 고치지 못하게 하려면 IReadOnlyList(Of T) 나 list.AsReadOnly() 로 넘기세요. ByVal 만으로는 막을 수 없습니다.

Optional, ParamArray, 오버로딩

Function Greet(name As String, Optional greeting As String = "Hello") As String
    Return $"{greeting}, {name}"
End Function

Function Sum(ParamArray values As Integer()) As Integer
    Dim t = 0
    For Each v In values
        t += v
    Next
    Return t
End Function

Function Describe(x As Integer) As String
    Return $"Integer {x}"
End Function
Function Describe(x As Double) As String
    Return $"Double {x}"
End Function
Function Describe(x As String) As String
    Return $"String ""{x}"""
End Function

Greet("Kim"), Greet("Kim", "Hi"), 이름 붙인 인수 Greet(greeting:="Yo", name:="Lee"), Sum()·Sum(1, 2, 3)·Sum({4, 5}), 그리고 Describe 에 1·1.5·"1"·1L 을 넘긴 결과입니다.

Hello, Kim
Hi, Kim
Yo, Lee
0 6 9
Integer 1
Double 1.5
String "1"
Double 1

마지막 줄이 함정입니다. Long 오버로드가 없고 Long → Integer 는 축소 변환이라, VB 는 아무 말 없이 확대 변환인 Long → Double 판을 고릅니다. 숫자 형식 오버로드를 만들 때는 호출하는 쪽이 실제로 넘기는 형식을 모두 준비하세요.

여기서 만나는 컴파일 오류:

error BC30812: 선택적 매개 변수는 기본값을 지정해야 합니다.
error BC30192: 매개 변수 목록의 끝이어야 합니다. paramarray 매개 변수 뒤에는 매개 변수를 정의할 수 없습니다.

첫째는 = 0 이 없는 Optional level As Integer, 둘째는 Sub Total(ParamArray xs As Integer(), label As String) 에서 나왔습니다. ParamArray 는 항상 마지막입니다.

If, Select Case, 반복문

VB 의 Select Case 는 C# 의 고전적인 switch 보다 표현력이 좋아서 범위·비교·값 목록을 한 Case 에 쓸 수 있습니다.

Function Grade(score As Integer) As String
    Select Case score
        Case Is > 100, Is < 0 : Return "invalid"
        Case 90 To 100 : Return "A"
        Case 80 To 89 : Return "B"
        Case 70, 75, 79 : Return "C (exact)"
        Case Else : Return "F"
    End Select
End Function
105->invalid  95->A  85->B  75->C (exact)  72->F  -1->invalid

72 가 Case Else 로 간 이유는 70, 75, 79 가 범위가 아니라 값 목록이기 때문입니다. Case 는 위에서부터 검사하고 처음 맞은 것만 실행하며, 다음 Case 로 흘러내리지 않습니다.

반복문은 Step, Continue For, Exit For 까지 돌려봤습니다.

For i = 10 To 1 Step -3
    Console.Write($"{i} ")
Next
Console.WriteLine()
For i = 1 To 10
    If i Mod 2 = 0 Then Continue For
    If i > 7 Then Exit For
    Console.Write($"{i} ")
Next
Console.WriteLine()
Dim k = 0
Do While k < 3
    k += 1
Loop
Dim j = 100
Do
    j += 1
Loop While j < 3
Console.WriteLine($"Do While: k={k}, Do...Loop While: j={j}")
Dim last = 5
For i = 1 To last
    last = 2           ' 상한은 처음 한 번만 계산된다
    Console.Write($"{i} ")
Next
10 7 4 1
1 3 5 7
Do While: k=3, Do...Loop While: j=101
1 2 3 4 5

포인트 두 가지. Do … Loop While 의 본문은 최소 한 번 실행되고(100 < 3 이 거짓인데도 j 는 101), For 의 상한은 한 번만 계산되므로 루프 안에서 last 를 바꿔도 소용이 없습니다. 끝 조건이 루프 도중에 바뀐다면 Do While 을 쓰세요. For 밖의 Exit For 는 error BC30096: 'Exit For'는 'For' 문 내부에만 사용할 수 있습니다. 입니다.

흔한 실수: AndAlso 대신 And

실수. 멤버 접근을 And 로 막으려 한다.

Class Customer
    Public Name As String
End Class

Function FindCustomer(id As Integer) As Customer
    If id = 1 Then Return New Customer With {.Name = "Alice"}
    Return Nothing
End Function

' Main 안에서
Dim c = FindCustomer(2)        ' id 2 는 Nothing
If c IsNot Nothing AndAlso c.Name = "Alice" Then ...   ' 괜찮음
If c IsNot Nothing And c.Name = "Alice" Then ...       ' 버그

실제로 일어나는 일. VB 의 And·Or 는 VB6 시절부터 내려온 연산자로 양쪽을 항상 평가합니다. Nothing 검사 결과와 상관없이 c.Name 도 평가됩니다.

AndAlso: not found, no exception
And: NullReferenceException: Object reference not set to an instance of an object.

해결. 조건식에는 AndAlso 와 OrElse 를 씁니다. 답이 정해지면 바로 멈추는, C# 의 &&·|| 와 같은 동작입니다. And/Or 는 비트 연산용으로 남겨 두세요. 거기서는 맞게 동작해서 5 And 3 = 1, 5 Or 3 = 7, Not 0 = -1(VB 의 True 는 정수로 바꾸면 -1)이 나왔습니다.

하고 싶은 것 쓸 것 오른쪽을 평가하나
If 의 논리곱 AndAlso 왼쪽이 True 일 때만
If 의 논리합 OrElse 왼쪽이 False 일 때만
정수·플래그의 비트 AND/OR And / Or 항상
양쪽 부수효과가 모두 필요 And / Or (드묾, 주석으로 명시) 항상

Try / Catch / Finally 와 When 필터

Function ParsePort(text As String) As Integer
    Try
        Console.WriteLine($"  try: parsing '{text}'")
        Return Integer.Parse(text)
    Catch ex As FormatException
        Console.WriteLine($"  catch FormatException")
        Return -1
    Catch ex As OverflowException When text.StartsWith("-")
        Console.WriteLine($"  catch OverflowException (negative)")
        Return 0
    Catch ex As OverflowException
        Console.WriteLine($"  catch OverflowException (positive)")
        Return 65535
    Finally
        Console.WriteLine($"  finally runs even after Return")
    End Try
End Function

입력 "8080", "abc", "99999999999", "-99999999999" 의 결과입니다.

  try: parsing '8080'
  finally runs even after Return
ParsePort -> 8080
  try: parsing 'abc'
  catch FormatException
  finally runs even after Return
ParsePort -> -1
  try: parsing '99999999999'
  catch OverflowException (positive)
  finally runs even after Return
ParsePort -> 65535
  try: parsing '-99999999999'
  catch OverflowException (negative)
  finally runs even after Return
ParsePort -> 0

Finally 는 Try 안의 Return 을 포함해 모든 Return 뒤에 실행됐습니다. Catch … When 은 필터라서 조건이 False 면 그 Catch 를 건너뛰고 다음 Catch 를 시도합니다(잡았다가 다시 던지는 게 아닙니다). VB 는 .NET 첫 버전부터 When 이 있었고, C# 에 같은 기능(catch … when)이 들어온 건 C# 6 입니다. 구체적인 예외와 필터 달린 Catch 를 먼저 쓰는 게 원칙입니다. 예외를 언제 잡을지에 대한 판단은 C# 예외 처리 글의 기준이 그대로 통합니다.

참고로 이 용도라면 실무에서는 Integer.TryParse 가 더 낫습니다. 자주 일어나는 예외는 느리기 때문입니다. Try/Catch 는 파일·네트워크·남의 코드처럼 미리 확인할 수 없는 것 주위에 둡니다.

실측: And 대 AndAlso, 루프 안 문자열 잇기

단락 평가는 안전 때문만이 아닙니다. 오른쪽이 무거우면 And 는 모든 행에서 그 비용을 냅니다. New Random(42) 로 만든 정수 100만 개에서, 가벼운 조건(x Mod 10 = 0)을 앞에, 일부러 무겁게 만든 조건(정규식이나 DB 조회 대신 200번의 해시 계산)을 뒤에 두고 개수를 셌습니다.

Imports System
Imports System.Diagnostics
Imports System.Text

Module Program
    Dim calls As Long

    Function IsValid(i As Integer) As Boolean
        calls += 1
        Dim h = i
        For k = 1 To 200
            h = (h * 31 + k) And &HFFFFF
        Next
        Return h Mod 7 <> 0
    End Function

    Function CountAnd(data As Integer()) As Integer
        Dim n = 0
        For Each x In data
            If x Mod 10 = 0 And IsValid(x) Then n += 1
        Next
        Return n
    End Function

    Function CountAndAlso(data As Integer()) As Integer
        Dim n = 0
        For Each x In data
            If x Mod 10 = 0 AndAlso IsValid(x) Then n += 1
        Next
        Return n
    End Function

    Function Concat(n As Integer) As Integer
        Dim s = ""
        For i = 1 To n
            s &= "x"
        Next
        Return s.Length
    End Function

    Function Builder(n As Integer) As Integer
        Dim sb As New StringBuilder()
        For i = 1 To n
            sb.Append("x"c)
        Next
        Return sb.ToString().Length
    End Function

    Sub Time(name As String, f As Func(Of Integer))
        f()   ' 워밍업
        For run = 1 To 3
            calls = 0
            Dim sw = Stopwatch.StartNew()
            Dim r = f()
            sw.Stop()
            Console.WriteLine($"{name,-14} run {run}: {sw.Elapsed.TotalMilliseconds,8:F2} ms  result={r} IsValid calls={calls}")
        Next
    End Sub

    Sub Main()
        Dim rnd As New Random(42)
        Dim data(999_999) As Integer
        For i = 0 To data.Length - 1
            data(i) = rnd.Next(1_000_000)
        Next
        Time("And", Function() CountAnd(data))
        Time("AndAlso", Function() CountAndAlso(data))
        For Each n In {10_000, 50_000, 100_000}
            Time($"&= {n}", Function() Concat(n))
            Time($"SB {n}", Function() Builder(n))
        Next
    End Sub
End Module

Release 빌드, Apple Silicon Mac (M1 Pro), 워밍업 1회 뒤 3회 측정:

방식 1회차 2회차 3회차 IsValid 호출 결과
And 315.97 ms 313.77 ms 312.89 ms 1,000,000 86,118
AndAlso 33.41 ms 33.04 ms 33.23 ms 100,381 86,118
막대그래프. And 에서는 무거운 검사가 100만 번 실행되어 루프가 312.9 ms 걸렸다. AndAlso 에서는 100,381 번만 실행되어 33.2 ms 였다. 두 방식 모두 같은 86,118 건을 셌다.
시드를 고정한 난수 100만 개에서 가벼운 조건을 앞에, 무거운 조건을 뒤에 두고 개수를 센 시간. Release 빌드, .NET 10, Apple Silicon Mac (M1 Pro), 워밍업 후 세 번째 실행.

답은 같고 약 9.4배 빠릅니다. 무거운 검사가 10으로 나누어떨어지는 100,381개에만 돌았기 때문입니다. 속도 차이는 거의 호출 횟수의 비율이므로, 내 데이터에서도 "왼쪽이 False 가 되는 비율"로 미리 짐작할 수 있습니다.

같은 프로그램에서 n 글자짜리 문자열을 만드는 시간도 쟀습니다.

n s &= "x" (3회차) StringBuilder (3회차)
10,000 9.26 ms 0.03 ms
50,000 114.55 ms 0.08 ms
100,000 511.12 ms 0.15 ms

글자 수가 5배가 되자 &= 시간은 약 12배, 다시 2배가 되자 약 4.5배 늘었습니다. &= 는 매번 문자열 전체를 복사하므로 시간이 길이의 제곱으로 늘어납니다. 엑셀로 넘길 CSV 를 루프에서 조립하는 것 같은 상황이라면 StringBuilder 나 String.Join 을 쓰세요. 몇 번 잇는 정도면 & 로 충분합니다.

솔직한 한계와 버전 메모

  • 모두 macOS 의 .NET SDK 10.0.403, 런타임 10.0.14, VB 17.13, Option Strict On 으로 빌드·실행했습니다. Windows 의 Visual Studio 나 .NET Framework 에서는 시험하지 않았습니다. AndAlso·OrElse·Catch … When 은 VB.NET 2002 부터, Continue For 는 VB 2005 부터 있었지만 확인은 .NET 10 에서만 했습니다.
  • 괄호로 감싼 인수 규칙(BumpRef((n)))은 ByRef 로 넘긴 속성 등 모든 식에 적용되지만, 실험한 건 지역 변수뿐입니다.
  • IsValid 의 작업은 인위적입니다. 속도 차이는 왼쪽이 False 가 되는 빈도와 오른쪽의 무게에 따라 달라집니다.
  • 컴파일러 메시지는 DOTNET_CLI_UI_LANGUAGE=ko 로 받은 한국어판입니다. BC 번호는 언어와 상관없이 같습니다.

이전 강좌: VB.NET 강좌 1/6: Option Strict·자료형·Nothing. 다음 강좌: VB.NET 강좌 3/6: 클래스·상속·인터페이스. 함께 보기: C# 메서드, C# if/else 와 switch, C# 반복문.

자주 묻는 질문

List 를 ByVal 로 넘겨도 프로시저 안에서 내용을 바꿀 수 있나요?

바꿀 수 있습니다. ByVal 이 복사하는 건 객체가 아니라 참조입니다. 실행해 보니 ByVal 로 받은 List 에 Add 한 항목이 호출한 쪽에도 보였습니다(a,added). 보이지 않은 건 매개변수에 새 List 를 다시 대입한 경우뿐이었습니다.

And 와 AndAlso 는 무엇이 다른가요?

And 는 항상 양쪽을 평가하고, AndAlso 는 왼쪽이 False 면 오른쪽을 평가하지 않습니다. c 가 Nothing 일 때 `c IsNot Nothing And c.Name = "Alice"` 는 NullReferenceException 을 냈고 AndAlso 판은 무사했습니다. 오른쪽이 무거울 때 AndAlso 33 ms, And 313 ms 였습니다.

Function 에서 Return 을 빠뜨리면 어떻게 되나요?

경고 BC42353(참조 형식이면 BC42105)만 나오고 빌드는 되며, 형식의 기본값이 반환됩니다. 이번 Discount(50D) 는 말없이 0 을 돌려줬습니다.

  • #VB.NET
  • #.NET 10
  • #ByRef
  • #AndAlso
  • #초급
  • #제어문

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑