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 그대로. 코드 정리하다 괄호를 "다듬었을" 때 생기는 단골 사고입니다.
받은 리스트를 고치지 못하게 하려면 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 |
답은 같고 약 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 을 돌려줬습니다.