Devin.KR

성능과 메모리 - 측정하고 고치기

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 도서관 데이터를 JSON으로 바꾸어 파일에 저장했다. 저장 기능이 정확하게 작동하더라도 대출 기록이 늘어나면 보고서를 만드는 시간이 길어지거나 임시 객체가 많이 생길 수 있다. 이런 문제는 코드를 짧게 만드는 것만으로 해결되지 않는다. 어디에서 비용이 생기는지 측정하고, 결과를 보존하면서 해당 부분을 바꾸어야 한다.

이 장에서는 작은 동네 도서관의 연체료 보고서를 대상으로 성능과 메모리를 살펴본다. 같은 보고서를 두 가지 방법으로 만들고, 값 형식을 객체로 다룰 때 생기는 비용과 컬렉션의 내부 공간을 확인한다. 마지막으로 사용이 끝난 자원을 정리하는 범위를 코드에 드러낸다. 측정값은 실행 환경에 따라 달라지므로 완성 프로그램은 시간 자체를 출력하지 않고, 결과가 같은지와 측정이 끝났는지를 결정적인 출력으로 확인한다.

  • Stopwatch로 측정 구간을 정하고, 준비 작업과 실제 작업을 구분한다.
  • 반복적인 문자열 연결을 StringBuilder로 바꾸되 최종 문자열의 내용과 순서를 유지한다.
  • 박싱(boxing)이 발생하는 경계를 알아보고 제네릭 컬렉션으로 불필요한 변환을 줄인다.
  • 컬렉션의 Count와 Capacity를 구분하고 예상 개수에 맞추어 초기 용량을 정한다.
  • IDisposable과 Using으로 자원 정리 시점을 명확하게 표현한다.

문제 상황

도서관 담당자는 매일 대출 기록에서 연체일수와 연체료를 뽑아 보고서를 만든다. 처음에는 대출 기록이 몇 개뿐이어서 문자열을 하나씩 연결해도 불편하지 않았다. 이후 보고서에 포함되는 기록이 늘고, 같은 보고서를 여러 차례 다시 만드는 기능이 추가되자 처리 시간이 눈에 띄기 시작했다.

기존 구현은 반복문에서 보고서 문자열 뒤에 행을 붙였다. 연체일수는 Object 목록에 담았고, 목록을 만들 때 필요한 개수를 이미 알고 있으면서도 초기 용량을 지정하지 않았다. 보고서를 읽는 스트림의 정리 시점도 분명하지 않았다. 각각은 작은 선택이지만 반복 횟수가 많아지면 문자열 복사, 객체 할당, 배열 확장 같은 비용으로 나타날 수 있다.

그렇다고 발견한 부분을 모두 한꺼번에 바꾸면 어떤 변경이 도움이 되었는지 알기 어렵다. 먼저 보고서 생성만 측정 대상으로 삼는다. 데이터를 만드는 시간, 결과를 화면에 쓰는 시간, 결과를 검사하는 시간은 측정 구간 밖에 둔다. 그다음 문자열 생성 방법을 바꾸고, 두 구현이 동일한 보고서를 만드는지 검사한다.

이 장의 데이터는 세 건으로 고정한다. 실제 서비스의 성능을 대표하기에는 작지만, 측정 경계와 검증 절차를 익히기에 적합하다. 실제 개선 여부를 판단할 때는 현장의 기록 수, 행 길이, 호출 빈도로 데이터를 바꾸어 다시 측정해야 한다. 작은 예제에서 얻은 순위를 서비스 전체의 결론으로 확대하지 않는다.

측정 구간을 먼저 정한다

Stopwatch는 경과 시간을 측정하는 도구다. StartNew로 시작하고 Stop으로 멈춘 뒤 Elapsed에서 경과 시간을 얻는다. 달력상의 현재 시각을 기록하는 목적과는 다르다. 처리 시간 측정에는 작업 시작 전후의 날짜를 빼는 코드보다 Stopwatch가 의도를 분명하게 드러낸다.

측정할 작업 앞에는 준비 실행을 둔다. .NET은 메서드를 처음 실행할 때 기계어로 컴파일하는 작업 등을 수행할 수 있으므로 최초 호출의 비용이 이후 호출과 다를 수 있다. 준비 실행이 모든 변동을 없애는 것은 아니지만, 처음 한 번의 특수한 비용을 그대로 비교하는 일을 줄인다.

한 번의 작업이 매우 짧으면 타이머를 읽는 비용과 주변 작업의 영향이 상대적으로 커진다. 예제에서는 보고서를 200번 만들어 한 구간으로 측정한다. 이때 측정하는 것은 보고서 한 개의 시간이 아니라 200회 실행의 전체 시간이다. 두 구현의 반복 횟수와 입력이 같아야 비교의 뜻이 유지된다.

준비와 검사는 측정 구간 밖에 두고 같은 작업의 반복만 Stopwatch로 측정한다

측정 안에서 Console.WriteLine을 호출하면 보고서 생성과 화면 출력의 비용이 섞인다. 두 구현의 결과를 비교하는 검사도 같은 이유로 밖에 둔다. 완성 코드에서는 마지막에 생성된 문자열을 함수 밖으로 돌려주고, 측정 종료 후 기대한 문자열과 비교한다. 결과를 실제로 사용하는 경로를 유지하면서 측정 구간을 좁히는 방법이다.

실제 비교에는 최적화된 빌드가 적합하므로 dotnet run -c Release를 사용한다. 디버거 연결 여부, 다른 프로세스의 부하, 가비지 수집(garbage collection), 실행 순서가 측정에 영향을 줄 수 있다. 같은 조건에서 여러 번 실행하고 값의 흔들림을 살펴야 한다. 뒤에 실행한 구현이 항상 유리하거나 불리한지 알아보려면 실행 순서를 바꾸는 것도 도움이 된다.

보고서 생성 시간을 비교할 때 고정할 조건
조건예제의 선택이유
입력같은 대출 기록 세 건처리량 차이를 제외한다.
반복 횟수각각 200회전체 실행량을 맞춘다.
측정 범위보고서 생성 함수 호출출력과 검사를 섞지 않는다.
결과 확인측정 종료 후 문자열 비교빠르지만 잘못된 구현을 걸러낸다.

예제는 어느 구현이 더 빠르다고 판정하지 않는다. 실제 경과 시간은 컴퓨터와 실행마다 달라지기 때문이다. 코드가 시간을 측정했다는 사실과 성능이 개선되었다는 판단은 구분해야 한다. 성능 판단에는 충분한 데이터와 반복 측정이 필요하다. 이 장에서는 경과 시간을 변수에 보관하고 출력은 고정된 검증 결과로 제한한다.

문자열과 컬렉션의 할당을 줄인다

반복 연결과 StringBuilder

String은 생성된 문자열의 내용을 직접 바꾸지 않는 형식이다. 문자열 변수에 다른 값을 대입할 수 있지만, 이는 기존 문자열 내부를 수정하는 것과 다르다. 반복문에서 누적 문자열과 새 행을 연결하면 새로운 결과 문자열이 만들어지고 기존 내용이 그 결과에 포함된다.

행을 계속 붙이는 작업에서는 앞서 만든 긴 내용이 다음 연결에 다시 들어간다. 행 길이가 비슷하고 실행 시 연결을 반복하는 전형적인 경우에는 누적되는 복사량이 행 수보다 빠르게 증가할 수 있다. 다만 짧은 문자열 몇 개를 한 번 연결하는 표현까지 같은 방식으로 해석해서는 안 된다. 컴파일러와 런타임의 처리 방식, 실제 호출 형태에 따라 비용이 달라진다.

StringBuilder는 변경 가능한 내부 버퍼에 내용을 추가한다. Append로 도서 번호, 구분자, 숫자를 차례로 넣고 작업 끝에 ToString을 호출하면 된다. 최종 String은 여전히 필요하며, 버퍼 공간이 부족하면 확장도 필요하다. 따라서 할당을 없애는 도구가 아니라 반복적인 누적 작업의 비용 구조를 바꾸는 도구로 이해한다.

예제에서는 두 구현이 공통의 AppendLoanLine을 사용한다. 원래 방식은 행마다 작은 StringBuilder로 행을 만든 뒤 누적 문자열에 연결한다. 개선 방식은 보고서 전체에 하나의 StringBuilder를 사용한다. 숫자 표기와 행 구성 코드를 공유하여 비교 중에 형식이 달라질 가능성을 줄인다.

StringBuilder의 초기 용량은 문자 수를 기준으로 한다. 예제의 128은 세 행과 합계 행을 담기에 충분한 작은 여유다. 실제 서비스에서 최대 보고서 크기를 무조건 예약하면 대부분의 요청에서 공간을 낭비할 수 있다. 자주 사용하는 크기를 출발점으로 잡고 큰 입력에서는 확장을 허용하는 선택도 가능하다.

값 형식이 Object로 넘어가는 순간

Integer 같은 값 형식을 Object로 변환하면 박싱이 발생한다. 값은 객체 안으로 복사되고 Object 변수는 그 객체를 참조한다. 원래 Integer 변수를 나중에 바꾸어도 이미 박싱한 객체 안의 값이 함께 바뀌지는 않는다. 객체를 다시 Integer로 꺼내는 변환을 언박싱(unboxing)이라고 한다.

Object를 보관하는 컬렉션은 여러 형식을 담을 수 있지만, 정수를 반복해서 넣을 때는 정수마다 박싱이 생길 수 있다. 정수 목록이라는 목적이 분명하면 List(Of Integer)를 사용한다. 요소를 Integer로 보관하므로 Object 요소 목록에 정수를 넣을 때의 박싱을 피하고, 잘못된 형식이 들어오는 것도 컴파일 시점에 제한한다.

Option Strict On은 이런 박싱을 모두 금지하는 설정이 아니다. 완성 코드의 CObj는 Integer를 Object로 바꾸겠다는 의도를 명시하며 정상적으로 컴파일된다. Object에서 Integer로 돌아올 때는 DirectCast를 사용한다. 박싱된 값의 실제 형식이 Integer여야 한다는 조건이 있다. 박싱된 다른 숫자 형식을 이 방식으로 숫자 변환하는 것은 별개의 문제다.

Count와 Capacity는 다른 수다

List(Of T)의 Count는 현재 들어 있는 요소 수다. Capacity는 내부 배열이 확장 없이 담을 수 있는 요소 수다. New List(Of Loan)(3)은 요소 세 개를 만드는 코드가 아니다. 세 요소를 담을 공간을 준비할 뿐이며 생성 직후 Count는 0이다.

내부 공간이 부족하면 더 큰 배열을 준비하고 기존 요소를 옮기는 작업이 필요하다. 필요한 개수를 이미 알 때 초기 용량을 지정하면 추가 중에 발생하는 이런 확장을 줄일 수 있다. 구체적인 확장 배수에 기대어 프로그램을 작성하지 않는다. 그것은 보고서의 업무 규칙이 아니며 이 장의 결과 검증에도 필요하지 않다.

완성 코드에서는 대출 목록을 용량 3으로 만들고 정확히 세 건을 추가한다. 따라서 출력되는 Count와 Capacity는 모두 3이다. 일반 서비스에서는 개수를 모를 수 있다. 그런 경우에는 합리적인 예상치로 시작하거나 기본 생성자를 사용하고, 측정에서 확장 비용이 문제가 되었을 때 조정한다.

List의 Count는 사용 중인 칸 수이고 Capacity는 준비된 전체 칸 수다

정리한다고 Clear를 호출해도 내부 용량이 곧바로 줄어드는 것은 아니다. 다시 채울 목록이라면 공간을 유지하는 편이 도움이 될 수 있다. 반대로 한 번 크게 사용한 목록을 오래 보관한다면 남은 공간이 부담이 될 수 있다. 사용 패턴에 따라 TrimExcess 등을 검토하되, 추가와 축소를 번갈아 반복하여 재할당을 늘리지 않도록 측정한다.

자원 정리 범위를 코드로 표현한다

메모리 사용을 논할 때는 객체가 더 이상 필요 없어진 시점과 자원이 정리되는 시점을 구분해야 한다. 관리되는 객체의 메모리는 런타임이 회수하지만, 파일 핸들 같은 자원을 언제 반납할지는 코드가 분명하게 표현하는 편이 좋다. 이를 위한 공통 계약이 IDisposable의 Dispose다.

Using은 IDisposable을 구현한 객체의 사용 범위를 정한다. 블록을 정상적으로 빠져나가든 블록 안에서 예외가 발생하든 정리 경로에서 Dispose가 호출된다. Dispose가 하는 구체적인 작업은 해당 형식의 계약에 따른다. 파일을 닫을 수도 있고, 가진 다른 자원을 정리할 수도 있다. 모든 객체의 메모리를 즉시 회수한다는 뜻은 아니다.

완성 코드에서는 보고서를 UTF-8 바이트로 바꾸어 MemoryStream에 담고 StreamReader로 첫 행을 읽는다. 디스크 파일을 사용하지 않으므로 파일 시스템과 운영체제의 줄바꿈 차이에 영향을 받지 않는다. 실제 파일 스트림도 같은 방식으로 Using의 범위를 정할 수 있다. 스트림 사용법 자체를 늘리기보다 정리 책임이 어디에 있는지를 확인하는 예제다.

안쪽 StreamReader에는 leaveOpen을 True로 지정한다. 리더가 정리될 때 바깥 스트림을 함께 닫지 않고, 바깥 Using이 스트림의 정리를 담당하게 한다. 이 예제에서는 리더를 정리한 다음 스트림을 다시 쓰지 않으므로 필수 선택은 아니지만, 두 객체의 소유 관계를 명시하는 효과가 있다.

자원의 소유자는 정리 책임을 가진 코드다. 호출자가 빌려준 스트림을 함수가 마음대로 닫으면 호출자의 다음 작업이 실패할 수 있다. 반대로 함수가 직접 만든 스트림을 아무도 정리하지 않으면 정리 시점이 늦어진다. 객체를 만든 위치만 살피는 데서 그치지 말고, API가 정한 소유 관계를 함께 확인한다.

사실 확인에는 Stopwatch API, StringBuilder API, List의 Capacity API, Visual Basic의 Using 문을 참고할 수 있다. 이 장의 설명과 도서관 예제는 각 기능의 계약을 바탕으로 새로 구성했다.

완성 코드

다음 코드를 Program.vb 전체 내용으로 사용한다. 보고서는 줄바꿈을 LF로 고정하고 숫자는 문화권에 영향을 받지 않는 형식으로 만든다. 경과 시간은 출력하지 않는다. 검사에 실패하면 예외로 종료하고, 정상 실행에서는 아래 실행 결과와 같은 줄을 출력한다.

Option Strict On
Option Explicit On
Option Infer On

Imports System
Imports System.Collections.Generic
Imports System.Diagnostics
Imports System.Globalization
Imports System.IO
Imports System.Text

Module Program
    Private Const FeePerDay As Integer = 300
    Private Const RepeatCount As Integer = 200
    Private Const LineFeed As String = vbLf

    ' 1. 보고서 입력을 표현한다.
    Private Structure Loan
        Public ReadOnly BookId As String
        Public ReadOnly OverdueDays As Integer

        Public Sub New(bookId As String, overdueDays As Integer)
            Me.BookId = bookId
            Me.OverdueDays = overdueDays
        End Sub
    End Structure

    Sub Main()
        ' 2. 예상 개수만큼 공간을 준비한다.
        Dim loans As New List(Of Loan)(3)
        loans.Add(New Loan("B001", 2))
        loans.Add(New Loan("B002", 0))
        loans.Add(New Loan("B003", 5))

        Dim expected As String =
            "B001 | 2 | 600" & LineFeed &
            "B002 | 0 | 0" & LineFeed &
            "B003 | 5 | 1500" & LineFeed &
            "합계 | 2100" & LineFeed

        ' 3. 준비 실행과 결과 검사를 측정 밖에서 수행한다.
        Check(BuildByConcatenation(loans) = expected, "연결 결과")
        Check(BuildByBuilder(loans) = expected, "버퍼 결과")

        Dim concatResult As String = String.Empty
        Dim builderResult As String = String.Empty

        Dim concatElapsed As TimeSpan =
            Measure(Function() BuildByConcatenation(loans),
                    RepeatCount, concatResult)
        Dim builderElapsed As TimeSpan =
            Measure(Function() BuildByBuilder(loans),
                    RepeatCount, builderResult)

        Check(concatResult = expected, "측정 후 연결 결과")
        Check(builderResult = expected, "측정 후 버퍼 결과")
        Check(concatElapsed.Ticks >= 0L, "연결 측정 완료")
        Check(builderElapsed.Ticks >= 0L, "버퍼 측정 완료")

        ' 4. 박싱된 값은 원래 변수와 독립된 복사본이다.
        Dim overdueDays As Integer = 2
        Dim boxedDays As Object = CObj(overdueDays)
        overdueDays = 7
        Dim savedDays As Integer = DirectCast(boxedDays, Integer)

        Dim dayValues As New List(Of Integer)(loans.Count)
        For Each loan As Loan In loans
            dayValues.Add(loan.OverdueDays)
        Next

        Check(savedDays = 2, "박싱된 복사본")
        Check(dayValues.Count = 3, "정수 목록")
        Check(loans.Count = 3 AndAlso loans.Capacity = 3, "대출 목록")

        ' 5. 중첩 Using으로 읽기 자원의 정리 범위를 정한다.
        Dim firstLine As String = ReadFirstLine(builderResult)
        Check(firstLine = "B001 | 2 | 600", "첫 행 읽기")

        Console.WriteLine("보고서 일치: 확인")
        Console.WriteLine("반복 측정: 완료")
        Console.WriteLine(
            "대출 목록: Count=" & NumberText(loans.Count) &
            ", Capacity=" & NumberText(loans.Capacity))
        Console.WriteLine(
            "박싱 값: " & NumberText(savedDays) &
            ", 변경한 원본: " & NumberText(overdueDays))
        Console.WriteLine("정수 목록 요소 수: " & NumberText(dayValues.Count))
        Console.WriteLine("첫 행: " & firstLine)
        Console.WriteLine("연체료 보고서:")
        Console.Write(builderResult)
        Console.WriteLine("모든 검사 통과")
    End Sub

    ' 6. 두 구현의 행 형식을 공유한다.
    Private Sub AppendLoanLine(target As StringBuilder, loan As Loan)
        target.Append(loan.BookId)
        target.Append(" | ")
        target.Append(NumberText(loan.OverdueDays))
        target.Append(" | ")
        target.Append(NumberText(loan.OverdueDays * FeePerDay))
        target.Append(LineFeed)
    End Sub

    Private Function BuildByConcatenation(
        loans As List(Of Loan)) As String

        Dim report As String = String.Empty
        Dim total As Integer = 0

        For Each loan As Loan In loans
            Dim row As New StringBuilder(32)
            AppendLoanLine(row, loan)
            report &= row.ToString()
            total += loan.OverdueDays * FeePerDay
        Next

        report &= "합계 | " & NumberText(total) & LineFeed
        Return report
    End Function

    Private Function BuildByBuilder(loans As List(Of Loan)) As String
        Dim report As New StringBuilder(128)
        Dim total As Integer = 0

        For Each loan As Loan In loans
            AppendLoanLine(report, loan)
            total += loan.OverdueDays * FeePerDay
        Next

        report.Append("합계 | ")
        report.Append(NumberText(total))
        report.Append(LineFeed)
        Return report.ToString()
    End Function

    ' 7. 반복 작업만 측정하고 마지막 결과를 돌려준다.
    Private Function Measure(
        build As Func(Of String),
        count As Integer,
        ByRef lastResult As String) As TimeSpan

        If count <= 0 Then
            Throw New ArgumentOutOfRangeException(NameOf(count))
        End If

        Dim watch As Stopwatch = Stopwatch.StartNew()
        For index As Integer = 1 To count
            lastResult = build()
        Next
        watch.Stop()

        Return watch.Elapsed
    End Function

    ' 8. 스트림과 리더의 정리 책임을 나눈다.
    Private Function ReadFirstLine(report As String) As String
        Dim bytes As Byte() = Encoding.UTF8.GetBytes(report)

        Using stream As New MemoryStream(bytes, writable:=False)
            Using reader As New StreamReader(
                stream, Encoding.UTF8,
                detectEncodingFromByteOrderMarks:=False,
                bufferSize:=128, leaveOpen:=True)

                Dim line As String = reader.ReadLine()
                If line Is Nothing Then
                    Throw New InvalidOperationException("보고서가 비어 있다.")
                End If
                Return line
            End Using
        End Using
    End Function

    Private Function NumberText(value As Integer) As String
        Return value.ToString(CultureInfo.InvariantCulture)
    End Function

    Private Sub Check(condition As Boolean, name As String)
        If Not condition Then
            Throw New InvalidOperationException("검사 실패: " & name)
        End If
    End Sub
End Module

줄별 해설

1. 입력 형식과 상수. Loan 구조체는 도서 번호와 연체일수를 보관한다. FeePerDay는 하루 연체료이며, RepeatCount는 측정할 실행 횟수다. LineFeed를 따로 두어 운영체제와 관계없이 보고서 문자열에 같은 줄바꿈 문자를 넣는다. 이 예제의 입력 범위에서는 Integer로 연체료를 계산할 수 있다. 서비스에서 허용하는 최대 일수와 건수가 커지면 합계의 범위를 별도로 검토해야 한다.

2. 목록 생성과 기대값. List 생성자의 3은 초기 용량이다. 이어지는 Add 세 줄이 실제 요소를 만든다. expected는 두 구현이 지켜야 할 보고서 내용이다. 실행 중 계산한 결과를 그대로 기대값으로 삼지 않고, 사람이 확인할 수 있는 고정 문자열을 사용한다.

3. 준비 실행과 측정 호출. 두 생성 함수를 한 번씩 호출하여 결과를 검사한다. 이어서 각 함수 호출을 Func(Of String)으로 전달한다. 델리게이트를 만드는 표현은 Measure에 들어가기 전에 평가된다. 측정 결과는 TimeSpan으로 받고, 보고서 문자열은 ByRef 매개변수로 받는다. 경과 시간의 음수 여부 검사는 측정 경로의 기본 확인일 뿐 속도 개선의 증명이 아니다.

4. 박싱 확인과 정수 목록. CObj가 overdueDays의 현재 값 2를 객체로 복사한다. 원래 변수를 7로 바꾼 뒤 DirectCast로 꺼내도 savedDays는 2다. 이어지는 List(Of Integer)는 각 연체일수를 정수 요소로 담는다. Integer 요소를 직접 보관하는 이 경로에는 Object 요소로 변환하는 박싱이 필요하지 않다.

5. 읽기 검사와 출력. 첫 행을 읽어 보고서가 읽기 경로에서도 유지되는지 확인한다. Console 출력은 두 측정이 모두 끝난 뒤 수행한다. Console.Write로 보고서를 쓰면 마지막 LF 뒤에 불필요한 빈 줄을 추가하지 않는다. 마지막 문장은 그 앞의 모든 검사가 성공했을 때만 출력된다.

6. 공통 행 생성과 두 누적 방식. AppendLoanLine은 한 행의 필드와 구분자를 순서대로 추가한다. 연결 구현은 매 행의 ToString 결과를 report에 연결한다. 버퍼 구현은 같은 report 버퍼에 모든 행을 추가하고 마지막에 한 번 ToString을 호출한다. NumberText가 만드는 숫자 문자열 등은 두 경로에 남아 있으므로 개선 구현도 할당이 없는 코드는 아니다.

7. 측정 함수. 반복 횟수가 0 이하이면 측정을 시작하기 전에 예외를 던진다. StartNew 이후에는 전달받은 작업을 반복하고 마지막 결과를 저장한다. Stop 이후 Elapsed를 반환한다. 작업 자체가 예외를 던지면 정상 측정값을 반환하지 않으며 프로그램도 성공 출력을 하지 않는다. Stopwatch에는 Using이 필요하지 않다.

8. 자원 정리와 검사 함수. UTF-8 변환은 Using 앞에서 수행된다. 스트림은 바깥 블록, 리더는 안쪽 블록이 정리한다. 안쪽에서 Return을 실행해도 두 Using의 정리 경로를 거친다. 빈 보고서는 Nothing 검사로 구분한다. Check는 조건이 거짓일 때만 예외를 던져 정상 출력과 실패를 명확히 나눈다.

실행 결과

새 프로젝트를 만들고 Program.vb를 완성 코드로 교체한다. 기본 실행 명령은 dotnet run이다. 측정 조건을 점검할 때는 아래처럼 Release 구성을 선택한다. 두 명령에서 프로그램이 출력하는 내용은 같다.

dotnet new console -lang VB -n LibraryPerformance
cd LibraryPerformance
dotnet run -c Release

예상 출력은 다음과 같다. 빌드와 복원 도구가 표시하는 메시지는 프로그램 출력에 포함하지 않는다.

보고서 일치: 확인
반복 측정: 완료
대출 목록: Count=3, Capacity=3
박싱 값: 2, 변경한 원본: 7
정수 목록 요소 수: 3
첫 행: B001 | 2 | 600
연체료 보고서:
B001 | 2 | 600
B002 | 0 | 0
B003 | 5 | 1500
합계 | 2100
모든 검사 통과

600과 1500의 합은 2100이다. 연체일수가 0인 기록도 보고서에 남긴다. 측정 중 만들어진 모든 보고서를 목록에 저장하지 않고 마지막 결과만 보관한다. 따라서 반복 횟수에 비례하여 결과 문자열을 계속 유지하는 구조는 아니다. 중간 결과는 도달할 수 없게 되면 런타임의 메모리 회수 대상이 된다.

실무에서 자주 틀리는 것

화면 출력까지 함께 측정한다

다음 코드는 보고서 생성뿐 아니라 콘솔 출력도 측정한다. 보고서 생성 방식의 차이를 확인하려는 목적에는 범위가 넓다.

Dim watch As Stopwatch = Stopwatch.StartNew()
Console.Write(BuildByBuilder(loans))
watch.Stop()

생성 결과를 변수에 받고 측정을 멈춘 다음 출력한다. 검사도 같은 위치에서 수행한다.

Dim watch As Stopwatch = Stopwatch.StartNew()
Dim report As String = BuildByBuilder(loans)
watch.Stop()
Check(report = expected, "보고서 결과")
Console.Write(report)

반복 연결을 바꾸면서 루프 안에서 ToString을 호출한다

StringBuilder를 도입해도 매 행마다 전체 버퍼를 문자열로 만들면 불필요한 결과 문자열이 계속 생긴다. 다음 코드에서 snapshot이 마지막 값으로만 사용된다면 중간 변환은 필요하지 않다.

Dim builder As New StringBuilder(128)
Dim snapshot As String = String.Empty
For Each loan As Loan In loans
    AppendLoanLine(builder, loan)
    snapshot = builder.ToString()
Next

전체 결과가 필요한 시점에 한 번 변환한다. 중간 상태를 실제로 전달해야 하는 요구가 있다면 그 요구에 맞는 비용을 따로 측정한다.

Dim builder As New StringBuilder(128)
For Each loan As Loan In loans
    AppendLoanLine(builder, loan)
Next
Dim snapshot As String = builder.ToString()

정수 목록을 Object 목록으로 만든다

정수만 보관하는데 Object 요소 목록을 사용하면 Add에서 박싱이 발생한다. 용량을 미리 정해도 박싱 비용까지 사라지지는 않는다.

Dim days As New List(Of Object)(loans.Count)
For Each loan As Loan In loans
    days.Add(loan.OverdueDays)
Next

목적에 맞는 요소 형식을 사용한다. Capacity는 내부 배열 공간에 대한 선택이고, 요소 형식은 값의 저장 방식에 대한 선택이다.

Dim days As New List(Of Integer)(loans.Count)
For Each loan As Loan In loans
    days.Add(loan.OverdueDays)
Next

마지막 줄의 Dispose에만 의존한다

직접 호출한 Dispose보다 앞에서 예외가 발생하면 정리 호출에 도달하지 못한다. 다음 코드는 정상 경로만 표현한다.

Dim stream As New MemoryStream(bytes, writable:=False)
Dim firstByte As Integer = stream.ReadByte()
Check(firstByte >= 0, "첫 바이트")
stream.Dispose()

Using으로 범위를 정하면 Check에서 예외가 발생해도 정리 경로를 거친다. 여기서 bytes는 완성 코드처럼 준비한 Byte 배열이다.

Using stream As New MemoryStream(bytes, writable:=False)
    Dim firstByte As Integer = stream.ReadByte()
    Check(firstByte >= 0, "첫 바이트")
End Using

한눈에 보기

성능과 자원 사용을 바꾸는 선택의 적용 범위
도구 또는 개념적용할 상황확인할 점
Stopwatch작업의 경과 시간을 비교한다.입력과 반복 횟수, 측정 범위를 맞춘다.
StringBuilder여러 행을 반복해서 누적한다.ToString 시점과 초기 문자 용량을 확인한다.
박싱값 형식이 Object 경계를 넘는다.실제 요소 형식과 변환 경로를 확인한다.
컬렉션 용량요소 수를 예상할 수 있다.Count와 Capacity를 구분한다.
Using정리 책임을 가진 자원을 사용한다.소유 관계와 블록 종료 시점을 확인한다.

개선 순서는 결과 확인, 측정, 변경, 재확인이다. 결과가 달라졌다면 속도 차이를 논하기 전에 기능을 바로잡는다. 결과가 같더라도 측정에서 이점이 드러나지 않으면 변경의 복잡도와 유지 비용을 함께 판단한다. 다음 종합 실습에서는 이 기준을 대출과 예약 관리 서비스의 실제 처리 경로에 적용할 수 있다.

연습 문제

  1. 대출 기록이 없는 입력을 두 보고서 생성 함수에 전달하라. 기대하는 문자열을 먼저 정하고 두 함수가 같은 결과를 반환하는지 Check로 검사하라.
  2. 용량 5인 List(Of Integer)를 만들고 요소 두 개를 추가한 다음 Clear를 호출하라. 추가 직후와 Clear 직후의 Count 및 Capacity를 예상하고 검사하라.
  3. Integer 값 4를 Object에 박싱한 다음 원래 변수에 9를 대입하라. Object에서 꺼낸 값이 무엇인지 검사하고, Object를 사용하지 않고 같은 두 값을 보관하는 코드를 작성하라.
  4. 보고서 읽기 함수가 첫 행을 읽은 직후 일부러 예외를 던진다고 가정하라. 두 Using의 정리 순서와 leaveOpen의 역할을 설명하라. 측정 중 강제로 GC.Collect를 호출하면 비교 조건이 어떻게 바뀌는지도 설명하라.

정답과 해설

1. 빈 보고서도 합계 행을 가진다

두 함수 모두 반복문을 건너뛰고 합계 행을 추가한다. 합계의 초깃값은 0이므로 기대값은 다음과 같다. 이 코드는 완성 코드의 Main 안에 추가할 수 있다.

Dim emptyLoans As New List(Of Loan)()
Dim emptyExpected As String = "합계 | 0" & LineFeed
Check(BuildByConcatenation(emptyLoans) = emptyExpected, "빈 연결 보고서")
Check(BuildByBuilder(emptyLoans) = emptyExpected, "빈 버퍼 보고서")

빈 입력을 검사하면 마지막 줄 처리와 합계 초기화가 반복문 실행 여부에 의존하지 않는지 확인할 수 있다. 속도를 바꾸는 과정에서도 이런 경계 조건을 보존해야 한다.

2. Clear는 요소를 제거하고 공간은 유지한다

Dim values As New List(Of Integer)(5)
values.Add(10)
values.Add(20)
Check(values.Count = 2 AndAlso values.Capacity = 5, "추가 후")
values.Clear()
Check(values.Count = 0 AndAlso values.Capacity = 5, "비운 후")

추가 직후에는 두 칸을 사용하고 전체 다섯 칸을 준비한 상태다. Clear 이후 사용 중인 요소는 없지만 용량은 5다. 다시 채우는 목록이라면 준비된 공간을 재사용할 수 있다. 비운 목록의 Count를 전체 예약 공간으로 해석하지 않는다.

3. 박싱한 값은 4로 남는다

Dim original As Integer = 4
Dim boxed As Object = CObj(original)
original = 9
Check(DirectCast(boxed, Integer) = 4, "박싱 값 유지")

Dim current As Integer = 4
Dim saved As Integer = current
current = 9
Check(saved = 4 AndAlso current = 9, "정수 복사")

두 번째 코드는 정수 변수끼리 값을 복사한다. 과거 값과 현재 값을 보관하려는 요구에는 Object가 필요하지 않다. 첫 번째 코드에서는 저장을 위해 객체를 만들지만, 두 번째 코드에서는 두 Integer 변수로 요구를 표현한다.

4. 안쪽 자원부터 정리한다

예외가 안쪽 Using에서 발생하면 먼저 StreamReader의 Dispose가 실행되고, 이어 바깥 MemoryStream의 Dispose가 실행된다. leaveOpen이 True이므로 리더의 정리가 스트림 정리를 맡지 않는다. 바깥 Using이 스트림을 정리한 뒤 예외가 호출자에게 전파된다. 예외가 발생했다는 이유로 Using의 정리 경로가 생략되지는 않는다.

GC.Collect를 측정 구간 안에 추가하면 보고서 생성 외에 강제 수집 비용까지 측정한다. 매 반복 전에 호출하면 실제 서비스와 다른 실행 조건을 만들 수 있다. 일반적인 비교에서는 자연스럽게 발생하는 할당과 수집의 영향을 포함하되, 강제 수집을 성능 개선 방법으로 넣지 않는다. 수집을 통제하는 별도 실험이 필요하다면 그 목적과 범위를 명시하고 일반 실행 결과와 구분한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.