자습서: 사용자 지정 문자열 보간 처리기 작성

이 자습서에서는 다음 작업을 수행하는 방법을 알아봅니다.

  • 문자열 보간 처리기 패턴 구현
  • 문자열 보간 작업에서 수신기와 상호 작용
  • 문자열 보간 처리기에 인수 추가
  • 문자열 보간을 위한 새로운 라이브러리 기능 이해

필수 조건

C# 10 컴파일러를 포함하여 .NET 6을 실행하도록 컴퓨터를 설정해야 합니다. C# 10 컴파일러는 Visual Studio 2022 또는 .NET 6 SDK부터 사용할 수 있습니다.

이 자습서에서는 여러분이 Visual Studio 또는 .NET CLI를 비롯한 C# 및 .NET에 익숙하다고 가정합니다.

새로운 개요

C# 10은 사용자 지정 ‘보간된 문자열 처리기’에 대한 지원을 추가합니다. 보간된 문자열 처리기는 보간된 문자열에서 자리 표시자 식을 처리하는 형식입니다. 사용자 지정 처리기가 없으면 자리 표시자는 String.Format과 비슷하게 처리됩니다. 각 자리 표시자는 텍스트 형식으로 지정되고 구성 요소는 결합되어 결과 문자열을 구성합니다.

결과 문자열에 관한 정보를 사용하는 모든 시나리오를 위한 처리기를 작성할 수 있습니다. 사용되나요? 형식에 대한 제약 조건은 무엇인가요? 일부 사례:

  • 결과 문자열이 80자 등의 일부 한도보다 크지 않아야 할 수 있습니다. 보간된 문자열을 처리하여 고정 길이 버퍼를 채우고 해당 버퍼 길이에 도달하면 처리를 중지할 수 있습니다.
  • 테이블 형식이 있을 수 있으며 각 자리 표시자에는 고정된 길이가 있어야 합니다. 사용자 지정 처리기는 모든 클라이언트 코드가 준수하도록 강제하는 대신 이를 적용할 수 있습니다.

이 자습서에서는 핵심 성능 시나리오 중 하나인 로깅 라이브러리에 대한 문자열 보간 처리기를 만듭니다. 구성된 로그 수준에 따라 로그 메시지를 생성하는 작업이 필요하지 않습니다. 로깅이 꺼져 있으면 보간된 문자열 식에서 문자열을 생성하는 작업이 필요하지 않습니다. 메시지는 인쇄되지 않으므로 문자열 연결을 건너뛸 수 있습니다. 또한 스택 추적 생성을 포함하여 자리 표시자에서 사용되는 식은 수행할 필요가 없습니다.

보간된 문자열 처리기는 형식이 지정된 문자열이 사용되는지 확인할 수 있으며 필요한 경우에만 필요한 작업을 수행합니다.

초기 구현

다양한 수준을 지원하는 기본 Logger 클래스에서 시작하겠습니다.

public enum LogLevel
{
    Off,
    Critical,
    Error,
    Warning,
    Information,
    Trace
}

public class Logger
{
    public LogLevel EnabledLevel { get; init; } = LogLevel.Error;

    public void LogMessage(LogLevel level, string msg)
    {
        if (EnabledLevel < level) return;
        Console.WriteLine(msg);
    }
}

Logger는 6가지 수준을 지원합니다. 메시지가 로그 수준 필터를 전달하지 않는 경우 출력이 없습니다. 로거의 퍼블릭 API는 (완전히 형식이 지정된) 문자열을 메시지로 허용합니다. 문자열을 만드는 모든 작업이 이미 수행되었습니다.

처리기 패턴 구현

이 단계는 현재 동작을 다시 만드는 ‘보간된 문자열 처리기’를 빌드하는 것입니다. 보간된 문자열 처리기는 다음 특징이 있어야 하는 형식입니다.

  • 형식에 적용된 System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute.
  • 두 개의 int 매개 변수인 literalLengthformattedCount가 있는 생성자. (추가 매개 변수가 허용됨).
  • public void AppendLiteral(string s) 시그니처가 있는 public AppendLiteral 메서드.
  • public void AppendFormatted<T>(T t) 시그니처가 있는 제네릭 public AppendFormatted 메서드.

내부적으로 작성기는 형식이 지정된 문자열을 만들고 클라이언트가 해당 문자열을 검색할 수 있도록 멤버를 제공합니다. 다음 코드는 다음 요구 사항을 충족하는 LogInterpolatedStringHandler 형식을 보여 줍니다.

[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
    // Storage for the built-up string
    StringBuilder builder;

    public LogInterpolatedStringHandler(int literalLength, int formattedCount)
    {
        builder = new StringBuilder(literalLength);
        Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    }

    public void AppendLiteral(string s)
    {
        Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
        
        builder.Append(s);
        Console.WriteLine($"\tAppended the literal string");
    }

    public void AppendFormatted<T>(T t)
    {
        Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");

        builder.Append(t?.ToString());
        Console.WriteLine($"\tAppended the formatted object");
    }

    internal string GetFormattedText() => builder.ToString();
}

이제 Logger 클래스의 LogMessage에 오버로드를 추가하여 새 보간된 문자열 처리기를 시도할 수 있습니다.

public void LogMessage(LogLevel level, LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

원래 LogMessage 메서드를 제거할 필요가 없습니다. 컴파일러는 인수가 보간된 문자열 식인 경우 string 매개 변수가 있는 메서드보다 보간된 처리기 매개 변수가 있는 메서드를 선호합니다.

다음 코드를 주 프로그램으로 사용하여 새 처리기가 호출되는지 확인할 수 있습니다.

var logger = new Logger() { EnabledLevel = LogLevel.Warning };
var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time}. This won't be printed.");
logger.LogMessage(LogLevel.Warning, "Warning Level. This warning is a string, not an interpolated string expression.");

애플리케이션을 실행하면 다음 텍스트와 유사한 출력이 생성됩니다.

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This won't be printed.}
        Appended the literal string
Warning Level. This warning is a string, not an interpolated string expression.

출력을 통해 추적하면 컴파일러가 처리기를 호출하고 문자열을 빌드하는 코드를 추가하는 방법을 확인할 수 있습니다.

  • 컴파일러는 처리기를 생성하는 호출을 추가하여 형식 문자열의 리터럴 텍스트 총 길이와 자리 표시자 수를 전달합니다.
  • 컴파일러는 리터럴 문자열의 각 섹션과 각 자리 표시자에 대해 AppendLiteralAppendFormatted에 대한 호출을 추가합니다.
  • 컴파일러는 CoreInterpolatedStringHandler를 인수로 사용하여 LogMessage 메서드를 호출합니다.

마지막으로, 마지막 경고는 보간된 문자열 처리기를 호출하지 않습니다. 인수는 string이므로 호출은 문자열 매개 변수가 있는 다른 오버로드를 호출합니다.

처리기에 더 많은 기능 추가

이전 버전의 보간된 문자열 처리기는 패턴을 구현합니다. 모든 자리 표시자 식을 처리하지 않으려면 처리기에 더 많은 정보가 필요합니다. 이 섹션에서는 생성된 문자열이 로그에 기록되지 않을 때 더 적은 작업을 수행하도록 처리기를 개선합니다. System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute를 사용하여 퍼블릭 API에 대한 매개 변수와 처리기의 생성자에 대한 매개 변수 간에 매핑을 지정합니다. 이렇게 하면 보간된 문자열을 평가해야 하는지 결정하는 데 필요한 정보가 처리기에 제공됩니다.

처리기 변경을 시작하겠습니다. 먼저 처리기가 사용되는지 추적할 필드를 추가합니다. 생성자에 두 개의 매개 변수를 추가합니다. 하나는 이 메시지의 로그 수준을 지정하고 다른 하나는 로그 개체에 대한 참조입니다.

private readonly bool enabled;

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel logLevel)
{
    enabled = logger.EnabledLevel >= logLevel;
    builder = new StringBuilder(literalLength);
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
}

다음으로, 마지막 문자열이 사용될 때 처리기가 리터럴 또는 형식이 지정된 개체만 추가하도록 필드를 사용합니다.

public void AppendLiteral(string s)
{
    Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
    if (!enabled) return;

    builder.Append(s);
    Console.WriteLine($"\tAppended the literal string");
}

public void AppendFormatted<T>(T t)
{
    Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");
    if (!enabled) return;

    builder.Append(t?.ToString());
    Console.WriteLine($"\tAppended the formatted object");
}

그런 다음, 컴파일러가 추가 매개 변수를 처리기의 생성자에 전달하도록 LogMessage 선언을 업데이트해야 합니다. 이는 처리기 인수에서 System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute를 사용하여 처리됩니다.

public void LogMessage(LogLevel level, [InterpolatedStringHandlerArgument("", "level")] LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

이 특성은 필수 literalLengthformattedCount 매개 변수 뒤에 오는 매개 변수에 매핑되는 LogMessage에 대한 인수 목록을 지정합니다. 빈 문자열(“”)은 수신기를 지정합니다. 컴파일러는 this가 나타내는 Logger 개체의 값을 처리기의 생성자에 대한 다음 인수로 대체합니다. 컴파일러는 level 값을 다음 인수로 대체합니다. 작성하는 모든 처리기에 대해 원하는 개수의 인수를 제공할 수 있습니다. 추가하는 인수는 문자열 인수입니다.

동일한 테스트 코드를 사용하여 이 버전을 실행할 수 있습니다. 이번에는 다음 결과가 표시됩니다.

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        AppendLiteral called: {. This won't be printed.}
Warning Level. This warning is a string, not an interpolated string expression.

AppendLiteralAppendFormat 메서드가 호출되고 있지만 작업을 수행하지 않는 것을 알 수 있습니다. 처리기는 마지막 문자열이 필요하지 않다고 결정했으므로 해당 문자열을 빌드하지 않습니다. 그래도 몇 가지 개선해야 할 사항이 있습니다.

먼저 System.IFormattable를 구현하는 형식으로 인수를 제한하는 AppendFormatted의 오버로드를 추가할 수 있습니다. 이 오버로드를 사용하면 호출자가 자리 표시자에 형식 문자열을 추가할 수 있습니다. 이렇게 변경하는 동안 다른 AppendFormattedAppendLiteral 메서드의 반환 형식도 void에서 bool로 변경해 보겠습니다(이러한 메서드에 다른 반환 형식이 있는 경우 컴파일 오류가 발생함). 이 변경은 ‘단락’을 가능하게 합니다. 메서드는 false를 반환하여 보간된 문자열 식의 처리를 중지해야 함을 나타냅니다. true를 반환하면 처리를 계속해야 함을 나타냅니다. 이 예제에서는 결과 문자열이 필요하지 않을 때 처리를 중지하는 데 해당 메서드를 사용하고 있습니다. 단락은 보다 세분화된 작업을 지원합니다. 고정 길이 버퍼를 지원하기 위해 특정 길이에 도달하면 식 처리를 중지할 수 있습니다. 또는 일부 조건은 나머지 요소가 필요하지 않음을 나타낼 수 있습니다.

public void AppendFormatted<T>(T t, string format) where T : IFormattable
{
    Console.WriteLine($"\tAppendFormatted (IFormattable version) called: {t} with format {{{format}}} is of type {typeof(T)},");

    builder.Append(t?.ToString(format, null));
    Console.WriteLine($"\tAppended the formatted object");
}

이 추가를 통해 보간된 문자열 식에서 형식 문자열을 지정할 수 있습니다.

var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. The time doesn't use formatting.");
logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time:t}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time:t}. This won't be printed.");

첫 번째 메시지의 :t는 현재 시간의 “짧은 시간 형식”을 지정합니다. 이전 예제에서는 처리기에 대해 만들 수 있는 AppendFormatted 메서드에 대한 오버로드 중 하나를 보여 주었습니다. 형식이 지정되는 개체의 제네릭 인수를 지정할 필요는 없습니다. 만드는 형식을 문자열로 변환하는 더 효율적인 방법이 있을 수 있습니다. 제네릭 인수 대신 해당 형식을 사용하는 AppendFormatted의 오버로드를 작성할 수 있습니다. 컴파일러는 최적 오버로드를 선택합니다. 런타임은 이 기술을 사용하여 System.Span<T>을 문자열 출력으로 변환합니다. 정수 매개 변수를 추가하여 IFormattable의 유무에 관계없이 출력의 ‘맞춤’을 지정할 수 있습니다. .NET 6과 함께 제공되는 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler에는 다른 용도를 위한 AppendFormatted의 오버로드 9개가 포함됩니다. 용도에 맞게 처리기를 빌드하는 동안 참조로 이를 사용할 수 있습니다.

이제 샘플을 실행하면 Trace 메시지에 대한 첫 번째 AppendLiteral만 호출되는 것을 알 수 있습니다.

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:18:29 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:18:29 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:18:29 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:18 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: Trace Level. CurrentTime:
Warning Level. This warning is a string, not an interpolated string expression.

효율성을 개선하는 처리기의 생성자에 대한 하나의 마지막 업데이트를 수행할 수 있습니다. 처리기는 마지막 out bool 매개 변수를 추가할 수 있습니다. 해당 매개 변수를 false로 설정하면 보간된 문자열 식을 처리하기 위해 처리기를 호출하지 않아야 함을 나타냅니다.

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel level, out bool isEnabled)
{
    isEnabled = logger.EnabledLevel >= level;
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    builder = isEnabled ? new StringBuilder(literalLength) : default!;
}

이러한 변경은 이제 enabled 필드를 제거할 수 있음을 의미합니다. 필드를 제거하면 AppendLiteralAppendFormatted 반환 형식을 void로 변경할 수 있습니다. 이제 샘플을 실행하면 다음 출력이 표시됩니다.

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:19:10 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:19:10 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:19 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
Warning Level. This warning is a string, not an interpolated string expression.

LogLevel.Trace가 지정된 경우 유일한 출력은 생성자의 출력입니다. 처리기가 해당 항목이 사용되지 않음을 나타냈으므로 Append 메서드가 호출되지 않았습니다.

이 예제에서는 특히 로깅 라이브러리가 사용되는 경우 보간된 문자열 처리기의 중요한 내용을 보여 줍니다. 자리 표시자의 부작용이 발생하지 않을 수 있습니다. 주 프로그램에 다음 코드를 추가하고 이 동작이 작동하는지 확인합니다.

int index = 0;
int numberOfIncrements = 0;
for (var level = LogLevel.Critical; level <= LogLevel.Trace; level++)
{
    Console.WriteLine(level);
    logger.LogMessage(level, $"{level}: Increment index a few times {index++}, {index++}, {index++}, {index++}, {index++}");
    numberOfIncrements += 5;
}
Console.WriteLine($"Value of index {index}, value of numberOfIncrements: {numberOfIncrements}");

루프가 반복될 때마다 index 변수가 5배 증분됨을 알 수 있습니다. 자리 표시자는 Critical, Error, Warning에 대해서만 평가되며 Information, Trace에 대해서는 평가되지 않으므로 index의 마지막 값이 예상과 일치하지 않습니다.

Critical
Critical: Increment index a few times 0, 1, 2, 3, 4
Error
Error: Increment index a few times 5, 6, 7, 8, 9
Warning
Warning: Increment index a few times 10, 11, 12, 13, 14
Information
Trace
Value of index 15, value of numberOfIncrements: 25

보간된 문자열 처리기를 사용하면 보간된 문자열 식이 문자열로 변환되는 방식을 더 효과적으로 제어할 수 있습니다. .NET 런타임 팀은 여러 영역에서 성능을 향상하는 데 이미 이 기능을 사용했습니다. 고유한 라이브러리에서 동일한 기능을 사용할 수 있습니다. 자세히 살펴보려면 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler를 참조하세요. 여기서 빌드한 것보다 더 완벽한 구현을 제공합니다. Append 메서드에 가능한 더 많은 오버로드가 표시됩니다.