멤버 이름을 코드에 직접 쓰지 않고 문자열로 받아서 그 멤버에 값을 넣거나 읽어야 할 때가 있습니다. 예를 들어 JSON이나 CSV의 키 이름이 그대로 객체 필드 이름과 같을 때, 키를 돌면서 한 번에 매핑하고 싶은 경우죠. VB.NET에서는 CallByName 으로 이걸 할 수 있습니다.

    <Serializable()> Public Structure PhotoUploadInfo
        Public Shared index As Integer
        Public Shared url As String
        Public Shared path As String
        Public Shared fileName As String
        Public Shared width As Integer
        Public Shared height As Integer
        Public Shared colorSpace As String
        Public Shared fileSize As Integer
        Public Shared thumbnail As String
    End Structure

    Dim PI As PhotoUploadInfo
    For Each str As String In {"index", "url", "path", "fileName", "width", "height", "colorSpace", "fileSize", "thumbnail"}
        CallByName(PI, str, CallType.Set, value)
    Next
    Return PI

    MsgBox(PhotoUploadInfo.url)

CallByName(대상, "멤버이름", CallType.Set, 값) 이 기본 형태입니다. 네 번째 인자로 넘긴 값을 세 번째 CallType.Set 지정에 따라 그 이름의 멤버에 대입합니다. 반대로 읽을 때는 CallType.Get 을 쓰고, 메서드를 부를 때는 CallType.Method 를 씁니다. 멤버 이름이 런타임에 문자열로 정해지니, 필드가 많을 때 Select Case 로 일일이 분기하지 않아도 됩니다.

광고

이 예제에 숨은 두 가지 함정

읽기에는 좋은 아이디어지만 이 코드를 그대로 쓰면 의도대로 동작하지 않습니다.

첫째, 모든 필드가 Public Shared 입니다. Shared 는 인스턴스가 아니라 타입 전체가 공유하는 변수라, PI 를 여러 개 만들어도 index·url 값은 딱 하나뿐입니다. 사진 정보를 여러 개 담으려고 구조체를 만든 거라면 Shared 를 빼야 합니다. 그래야 PI 마다 따로 값을 가집니다. 맨 끝의 MsgBox(PhotoUploadInfo.url) 가 인스턴스 PI.url 이 아니라 타입 이름으로 접근하고 있는 것도 전부 Shared 라서 가능한 건데, 이게 바로 공유되고 있다는 증거입니다.

둘째, 루프가 모든 필드에 똑같은 value 하나를 넣고 있습니다. 필드마다 다른 값이 들어가야 의미가 있으니, 실제로는 이름과 값이 짝지어진 자료(딕셔너리 등)를 돌면서 넣어야 합니다.

Shared를 떼고 딕셔너리로 돌리면

    Public Structure PhotoUploadInfo
        Public index As Integer
        Public url As String
        Public path As String
        Public fileName As String
        Public width As Integer
        Public height As Integer
        Public colorSpace As String
        Public fileSize As Integer
        Public thumbnail As String
    End Structure

    Dim PI As New PhotoUploadInfo
    Dim data As New Dictionary(Of String, Object) From {
        {"url", "http://..."}, {"width", 800}, {"height", 600}
    }
    For Each kv In data
        CallByName(PI, kv.Key, CallType.Set, kv.Value)
    Next
    Return PI

Shared 를 떼고, 키·값 쌍을 돌면서 각 이름에 해당 값을 넣는 형태입니다. 그런데 이 코드도 구조체(Structure)인 채로는 값이 들어가지 않습니다. .NET Framework 4.x 에서 직접 컴파일해 돌려 보면 오류 없이 지나가는데, 루프가 끝난 뒤 PI.url 은 빈 값이고 PI.width 는 0 그대로입니다.

구조체에서는 왜 값이 안 들어가나

CallByName 의 첫 인자는 Object 형식입니다. 구조체 같은 값 형식을 Object 자리에 넘기면 그 순간 복사본이 만들어지고(박싱), CallByName 은 그 복사본의 필드를 바꿉니다. 원래 변수 PI 는 손대지 않은 상태로 남죠. 미리 Dim boxed As Object = PI 로 박싱해 두고 넘기는 방법도 생각할 수 있지만, VB는 Object 변수에 든 구조체를 인자로 넘길 때도 다시 복사해서 넘기기 때문에 이것 역시 원본이 바뀌지 않습니다. 리플렉션의 FieldInfo.SetValue 로 같은 시도를 해도 결과가 같았습니다.

그래서 이름으로 값을 채우는 대상은 클래스로 만드는 게 맞습니다. 클래스는 참조 형식이라 CallByName 이 원본 객체를 그대로 고칩니다.

Public Class PhotoUploadInfo
    Public index As Integer
    Public url As String
    Public width As Integer
    Public height As Integer
End Class

Function ToPhoto(data As Dictionary(Of String, Object)) As PhotoUploadInfo
    Dim PI As New PhotoUploadInfo
    For Each kv In data
        CallByName(PI, kv.Key, CallType.Set, kv.Value)
    Next
    Return PI
End Function

ToPhoto(New Dictionary(Of String, Object) From {{"url", "http://example.com/a.jpg"}, {"width", 800}, {"height", "600"}}) 로 부르면 url, width, height 가 각각 채워진 객체가 돌아옵니다. height 에 문자열 "600" 을 넣었는데도 정수 600으로 들어가는데, CallByName 이 늦은 바인딩이라 VB의 형식 변환 규칙을 그대로 적용하기 때문입니다. CSV처럼 모든 값이 문자열로 들어오는 자료를 매핑할 때는 이 점이 오히려 편합니다.

이름이 틀리거나 값이 안 맞으면

한 가지 더, CallByName 은 이름을 문자열로 처리하기 때문에 컴파일 시점에 오타를 못 잡습니다. 없는 이름을 넘기면 실행 중에 MissingMemberException 이 나고, 값 타입이 안 맞아도(width 에 "abc" 같은 문자열을 넣는 등) InvalidCastException 으로 터집니다. 외부에서 받은 키를 그대로 넘기는 구조라면 루프 안에서 이 두 예외를 잡아 어떤 키가 문제였는지 로그로 남겨 두는 게 좋습니다. 성능도 직접 대입보다 느리니, 필드 이름이 코드에 고정돼 있다면 그냥 PI.url = ... 로 쓰는 게 낫습니다. 이 방식은 키가 런타임에 정해지는 경우에만 득이 있습니다. 리플렉션(GetType(...).GetField(name).SetValue(...))을 써도 되지만, VB에서는 CallByName 이 더 짧게 끝납니다.