← 문제로

47. 문자열 보간의 컴파일러 변환과 로깅에서의 숨은 비용

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 문자열 보간의 컴파일러 변환과 로깅에서의 숨은 비용

난이도: 상

요약

$"..." 는 대상에 따라 다르게 낮춰진다. string 으로 쓰면 C# 10+ 는 DefaultInterpolatedStringHandler 로 조각을 append 해 조립하고(값 형식은 제네릭 AppendFormatted<T> 라 박싱 없음), 과거에는 string.Format(object[]) 로 낮춰져 인자 배열 + 박싱이 있었다. 핸들러를 받는 API(예: StringBuilder.Append) 에 넘기면 중간 string 조차 안 만든다. 그러나 string 매개변수 로깅에 보간을 넘기면 레벨과 무관하게 문자열이 항상 조립된다.

핵심 개념 / 각 지점 분석

  • (A) string s = $"pos=({x},{y})" — string 대상:
    • C# 10+: 컴파일러가 DefaultInterpolatedStringHandler 를 스택에 만들고 AppendLiteral("pos=("), AppendFormatted(x), AppendLiteral(","), AppendFormatted(y), AppendLiteral(")") 를 호출한 뒤 ToStringAndClear() 로 최종 문자열을 얻는다. AppendFormatted<T> 는 제네릭이라 int 를 박싱하지 않고 내부 버퍼(ArrayPool 활용)에 직접 포맷한다.
    • 과거(또는 object[] 경로): string.Format("pos=({0},{1})", x, y) 로 낮춰져 object[2] 가 할당되고 x, y 가 각각 박싱된다.
  • (B) sb.Append($"hp={hp} "): StringBuilder.Append 에는 보간 핸들러를 받는 오버로드 (Append(ref DefaultInterpolatedStringHandler))가 있다. 컴파일러가 핸들러를 sb 에 직접 append 하도록 낮추므로 중간 string 을 만들지 않고, int hp 도 박싱 없이 붙는다.
  • (C) string.Format("hp={0}", hp): object[1] 할당 + hp 박싱 + 포맷 파싱. (B) 보다 할당·박싱이 많다.
  • (D) LogDebug(enabled, $"expensive={Expensive()}") — string 매개변수: 인자가 string 이므로 호출 전에 보간이 완전히 평가된다. 즉 Expensive() 가 호출되고("Expensive() 호출됨" 출력) 문자열이 조립된 뒤 LogDebug 로 넘어간다. 메서드 안에서 enabled==false 라 출력만 건너뛸 뿐, 비싼 계산과 문자열 조립은 이미 끝났다. 로그가 꺼져 있어도 비용을 다 치른다.

단계별 내부 동작 (핸들러 경로)

  1. 컴파일러가 리터럴 길이·보간 구멍 수를 세어 핸들러를 생성(new DefaultInterpolatedStringHandler(litLen, holeCount)).
  2. 순서대로 AppendLiteral/AppendFormatted<T> 호출 IL 을 낸다. 핸들러는 내부에 char 버퍼(스택/풀)를 두고 직접 쓴다.
  3. 대상이 string 이면 마지막에 ToStringAndClear(); 핸들러를 받는 API 면 그 API 가 핸들러 상태를 직접 소비한다.
  4. 조건부 핸들러: API 가 [InterpolatedStringHandlerArgument(...)] 로 "만들어야 하는지" 를 생성자에 전달받으면, 조립 전에 handler.AppendFormatted 를 아예 호출하지 않도록 컴파일러가 enabled 검사를 삽입한다(예: Debug.Assert, ILogger 의 핸들러 지원). 이러면 꺼진 로그의 비용이 사라진다.

흔한 오해·함정

  • "보간은 항상 string.Format 이다" — C# 10+ 는 핸들러 기반이라 값 형식 박싱과 object[] 를 대개 없앤다.
  • 꺼진 로그의 숨은 비용((D)): string 매개변수 로깅에 보간을 넣으면 레벨과 무관하게 계산·조립이 일어난다. 회피법: (1) 핸들러를 지원하는 로깅 API(LoggerMessage.Define/소스 생성 로깅, 또는 보간 핸들러 오버로드)를 쓰거나, (2) if (logger.IsEnabled(level)) 로 감싸거나, (3) 구조화 로깅 템플릿(Log("hp={Hp}", hp))을 쓴다.
  • AppendFormatted<T> 도 박싱될 때: T 가 값 형식이면 안 박싱되지만, 정렬/포맷 지정({x,5:N0})이나 일부 경로에서 ISpanFormattable 미구현 타입은 여전히 할당이 생길 수 있다.
  • 핸들러는 ref struct: 스택 전용이라 async 의 await 경계를 넘겨 보관할 수 없다.

면접 포인트

  • 보간이 대상(문자열 vs 핸들러 API)에 따라 다르게 낮춰지는 것과, 핸들러 경로가 박싱·중간 문자열을 없애는 원리.
  • 꺼진 로그에서도 string 매개변수 보간이 비용을 치르는 이유와, 조건부 핸들러/구조화 로깅으로 피하는 법.
  • AppendFormatted<T> 의 무박싱 포맷과 DefaultInterpolatedStringHandler 가 ref struct 인 이유.