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가 각각 박싱된다.
- C# 10+: 컴파일러가
- (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라 출력만 건너뛸 뿐, 비싼 계산과 문자열 조립은 이미 끝났다. 로그가 꺼져 있어도 비용을 다 치른다.
단계별 내부 동작 (핸들러 경로)
- 컴파일러가 리터럴 길이·보간 구멍 수를 세어 핸들러를 생성(
new DefaultInterpolatedStringHandler(litLen, holeCount)). - 순서대로
AppendLiteral/AppendFormatted<T>호출 IL 을 낸다. 핸들러는 내부에char버퍼(스택/풀)를 두고 직접 쓴다. - 대상이
string이면 마지막에ToStringAndClear(); 핸들러를 받는 API 면 그 API 가 핸들러 상태를 직접 소비한다. - 조건부 핸들러: 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인 이유.