PHpullh

C# · 심층 가이드

C# 성능 완전 정리

문자열 버퍼링과 사전 할당부터 배치 I/O, 청크 스트리밍, 메모이제이션까지 C# 코드의 할당과 시간을 실제로 줄이는 15가지 기법을 다룹니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

관리 런타임에서 성능 이야기는 대개 CPU가 아니라 할당 이야기입니다. GC가 알아서 정리해 준다는 사실이 오히려 문제를 가립니다. 짧은 수명 객체를 대량으로 만들면 Gen0 수집이 잦아지고, 큰 배열을 반복해서 새로 잡으면 대형 객체 힙 쪽이 부담을 집니다. C#에서 최적화는 보통 "더 빠른 알고리즘"보다 "덜 만드는 코드"에서 시작합니다. LINQ가 편한 만큼 중간 시퀀스를 얼마나 만들어 내는지도 같이 봐야 합니다.

이 가이드는 세 갈래로 묶어 읽으면 좋습니다. 메모리 쪽은 문자열 버퍼링·사전 할당·불필요한 복사 줄이기·객체 재사용이 한 묶음입니다. 자료구조 쪽은 조회 맵 사용과 Set 조회 최적화가 선형 탐색을 해시 탐색으로 바꾸는 같은 아이디어의 두 변주입니다. 데이터 양 쪽은 지연 처리에서 시작해 청크 스트리밍, 배치 I/O, 페이지네이션 슬라이스로 이어집니다. 시간 측정은 어느 갈래를 고르든 먼저 읽는 편이 낫습니다.

실무에서 가장 많이 데는 지점은 지연 실행입니다. LINQ 쿼리는 열거될 때 평가되므로, 같은 IEnumerable을 Count()와 foreach에서 두 번 훑으면 원본 계산이 두 번 돌아갑니다. 소스가 DB 커서나 파일 스트림이면 두 번째 열거에서 예외가 나기도 합니다. 결과를 재사용할 거면 한 번만 구체화하십시오. 그리고 경과 시간은 DateTime.Now가 아니라 Stopwatch로 재야 합니다. 벽시계는 시스템 시각 보정에 따라 뒤로 갈 수 있습니다.

01문자열 버퍼링

StringBuilder에 접두사와 숫자 1을 이어 붙인 뒤 ToString으로 완성된 문자열을 얻습니다. 반복문 안에서 문자열을 계속 이어 붙일 때 이 방식이 필요합니다. Append(int)는 숫자를 버퍼에 바로 써 넣어 중간 문자열을 만들지 않지만, 붙일 조각이 두세 개뿐이면 + 연결이 더 빠릅니다.

C# code

// 문자열 버퍼링
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(1);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

생성자에 예상 길이를 넘겨 new StringBuilder(1024)로 만들면 내용을 쌓는 동안 내부 버퍼를 늘리며 다시 할당하는 과정을 줄일 수 있습니다.

자주 하는 실수

루프를 돌 때마다 StringBuilder를 새로 만들면 버퍼링의 이점이 사라집니다. 인스턴스를 루프 밖에 두고 Clear로 비워 재사용해야 할당이 줄어듭니다.

02사전 할당

문자열 세 개를 컬렉션 초기화 구문으로 HashSet에 넣고 포함 여부를 확인합니다. 담을 개수를 미리 알고 있다면 new HashSet<string>(capacity)로 만들어 두는 편이 좋습니다. 용량을 주지 않으면 원소가 늘 때마다 내부 버킷을 다시 잡고 기존 항목을 전부 재배치합니다.

C# code

// 사전 할당
using System;
using System.Collections.Generic;

var tags = new HashSet<string> { "cs", "dotnet", "rust" };
Console.WriteLine(tags.Contains("dotnet"));
알아두면 좋은 점

List<T>도 생성자로 용량을 받으며, 내부 배열이 가득 차면 두 배로 늘리면서 기존 원소를 전부 새 배열로 복사합니다.

자주 하는 실수

용량을 지나치게 크게 잡으면 쓰지도 않을 배열이 통째로 잡히고, 아주 큰 배열은 대형 객체 힙으로 올라가 회수 시점이 늦어집니다.

03지연 처리

빌더에 조각을 쌓아 두었다가 ToString을 부르는 순간에만 실제 문자열이 만들어집니다. 결과가 필요할 때까지 계산을 미루는 방식은 LINQ의 지연 실행과 같은 발상입니다. ToString은 부를 때마다 버퍼 내용을 새 문자열로 복사하므로 루프 안에서 반복 호출하면 이득이 사라집니다.

C# code

// 지연 처리
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(3);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

Lazy<T>는 값이 처음 필요할 때 한 번만 초기화하며, 기본 설정에서는 여러 스레드가 동시에 접근해도 초기화가 한 번만 일어나도록 보장합니다.

자주 하는 실수

지연 처리는 계산을 없애는 것이 아니라 미루는 것이라, 결국 모든 값이 필요한 경로에서는 이득이 없고 열거자와 상태 관리 비용만 더해집니다.

04메모이제이션 캐시

이 코드는 집합에 값이 있는지 확인만 하고 결과를 보관하지는 않습니다. 계산 결과를 재사용하려면 값을 함께 저장하는 Dictionary가 필요합니다. HashSet은 존재 여부만 다루므로 계산한 적이 있다는 사실은 기억해도 그 결과가 무엇이었는지는 남기지 못합니다.

C# code

// 메모이제이션 캐시
using System;
using System.Collections.Generic;

var tags = new HashSet<string> { "cs", "dotnet", "rust" };
Console.WriteLine(tags.Contains("dotnet"));
알아두면 좋은 점

결과가 null일 수도 있다면 TryGetValue로 키의 존재 자체를 확인해야 합니다. 값이 null인지 보는 방식으로는 계산한 적 없음과 결과가 null임을 구분할 수 없습니다.

자주 하는 실수

캐시는 계산이 순수할 때만 안전합니다. 입력이 같아도 결과가 시간이나 외부 상태에 따라 달라지는 함수를 캐싱하면 낡은 값을 계속 돌려주게 됩니다.

05조회 맵 사용

빌더가 접두사와 번호를 이어 붙여 item-5 같은 키 문자열을 만듭니다. 이런 키로 사전을 조회하면 조건문을 길게 늘어놓는 대신 분기를 표로 대체할 수 있습니다. 조회할 때마다 키 문자열을 새로 만들면 할당이 계속 생기므로, 키가 정수라면 문자열로 바꾸지 않고 그대로 쓰는 편이 낫습니다.

C# code

// 조회 맵 사용
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(5);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

정수 키 사전은 문자열 키보다 해시 계산과 비교가 모두 싸고, 키가 연속된 작은 정수라면 사전 대신 배열 인덱싱으로 바꾸는 것이 가장 빠릅니다.

자주 하는 실수

두 값을 합쳐 키 문자열을 만들면 조회할 때마다 문자열이 새로 할당됩니다. 이런 복합 키는 값 튜플을 그대로 사전의 키로 쓰면 할당 없이 처리됩니다.

06배치 I/O

메모리 안 집합을 한 번 조회하는 코드로, 디스크나 네트워크는 건드리지 않습니다. 조회 대상이 외부 저장소라면 건마다 왕복하지 말고 키를 모아 한 번에 요청한 뒤 결과를 이런 집합에 담아 둡니다. 왕복 비용은 대개 데이터 크기보다 횟수에 좌우되므로 묶는 것만으로 차이가 큽니다.

C# code

// 배치 I/O
using System;
using System.Collections.Generic;

var tags = new HashSet<string> { "cs", "dotnet", "rust" };
Console.WriteLine(tags.Contains("dotnet"));
알아두면 좋은 점

한 번에 보낼 키 수는 무한정 늘릴 수 없으므로, 상대 서버의 요청 크기 제한과 타임아웃을 감안해 적당한 묶음 크기를 미리 정해 두어야 합니다.

자주 하는 실수

배치로 묶으면 하나가 실패했을 때 어느 항목이 실패했는지 알기 어려워집니다. 응답에서 항목별 결과를 구분할 수 없다면 부분 실패가 전체 실패로 처리됩니다.

07불필요한 복사 줄이기

Append는 자기 자신을 돌려주어 체이닝되고, 내부 버퍼에 바로 쓰기 때문에 중간 결과 문자열이 남지 않습니다. 문자열을 +=로 이어 붙이는 반복 구간이 있다면 이 형태로 바꿉니다. C#의 string은 불변이라 수정처럼 보이는 연산도 전부 새 객체를 만들어 냅니다.

C# code

// 불필요한 복사 줄이기
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(7);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

문자열의 일부만 필요하다면 Substring 대신 AsSpan으로 잘라 쓰면 새 문자열을 만들지 않고 원본의 구간을 그대로 들여다볼 수 있습니다.

자주 하는 실수

Span<T>는 원본을 가리키는 뷰라서 필드에 저장하거나 await를 넘어 살릴 수 없습니다. 컴파일 오류로 막히지만 설계 자체를 바꿔야 하는 경우가 많습니다.

08시간 측정

이 코드에는 측정 장치가 없고 집합 조회 결과만 출력합니다. 해시 조회처럼 극히 짧은 연산은 한 번 재서는 의미가 없어 수만 번 반복한 총시간을 나눠 봐야 합니다. Stopwatch로 직접 잴 때는 JIT 예열과 GC 시점이 섞이므로, 정밀한 비교가 필요하면 BenchmarkDotNet 같은 도구를 씁니다.

C# code

// 시간 측정
using System;
using System.Collections.Generic;

var tags = new HashSet<string> { "cs", "dotnet", "rust" };
Console.WriteLine(tags.Contains("dotnet"));
알아두면 좋은 점

Stopwatch.Frequency로 타이머의 해상도를 알 수 있고, 밀리초 단위가 너무 거칠다면 ElapsedTicks를 써서 더 세밀하게 잴 수 있습니다.

자주 하는 실수

측정 대상이 짧으면 타이머를 시작하고 멈추는 비용 자체가 결과에 섞입니다. 반복 횟수를 늘려 총시간을 재고 나누는 방식으로 바꿔야 합니다.

09병렬 처리

빌더 하나에 순서대로 덧붙이는 단일 스레드 코드입니다. 작업을 여러 스레드로 나누려면 스레드마다 각자의 빌더를 두고 마지막에 합쳐야 합니다. StringBuilder는 스레드 안전하지 않아 하나를 공유한 채 동시에 Append하면 내용이 섞이거나 예외가 납니다.

C# code

// 병렬 처리
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(9);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

Parallel.ForEach에는 스레드마다 지역 상태를 두고 마지막에 합치는 오버로드가 있어, 공유 락 없이 부분 결과를 모을 수 있습니다.

자주 하는 실수

병렬화는 항목당 작업이 충분히 무거울 때만 이득입니다. 짧은 작업에 쓰면 분할과 스레드 전환 비용이 계산 시간을 넘어 오히려 느려집니다.

10객체 재사용

집합을 한 번 만들어 두고 조회에 계속 사용하는 형태입니다. 같은 내용을 호출마다 새로 만들지 말고 필드나 static readonly로 올려 두면 할당이 사라집니다. 다만 여러 스레드가 공유하는 HashSet에 나중에 원소를 추가하면 읽는 쪽과 충돌하므로, 재사용할 것은 만든 뒤 건드리지 않아야 합니다.

C# code

// 객체 재사용
using System;
using System.Collections.Generic;

var tags = new HashSet<string> { "cs", "dotnet", "rust" };
Console.WriteLine(tags.Contains("dotnet"));
알아두면 좋은 점

static readonly로 둔 컬렉션은 타입을 처음 쓸 때 한 번만 초기화되며, 그 초기화 중에 예외가 나면 TypeInitializationException으로 감싸여 올라옵니다.

자주 하는 실수

재사용하려고 인스턴스를 공유했는데 각 호출이 그 안의 상태를 바꾸면 호출끼리 값이 섞입니다. 공유할 것은 읽기 전용으로 확정된 데이터여야 합니다.

11Set 조회 최적화

문자열을 조립해 키 하나를 만드는 코드이고 집합 조회 자체는 들어 있지 않습니다. 포함 검사를 자주 한다면 List의 Contains 대신 HashSet을 두어 원소 수와 무관한 조회로 바꿉니다. 대신 원소가 열 개 안팎이면 배열을 그냥 훑는 쪽이 해시 계산 비용이 없어 더 빠를 수 있습니다.

C# code

// Set 조회 최적화
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(11);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

집합을 만들 때 비교자를 지정하면 조회 방식까지 고정되고, StringComparer.Ordinal은 문화권 규칙을 타지 않아 해시 계산도 더 가볍습니다.

자주 하는 실수

조회할 때마다 리스트로부터 새 집합을 만들면 집합을 구성하는 비용이 조회 절감을 넘어서, 원래의 선형 탐색보다 오히려 느려집니다.

12청크 스트리밍

집합 전체를 메모리에 올려 두고 조회하는 방식이라 원소 수만큼 메모리를 씁니다. 입력이 커서 한 번에 담을 수 없다면 일정 개수씩 끊어 읽고 처리한 뒤 버립니다. IEnumerable로 흘려보내면 항목 하나씩만 메모리에 남지만, 중간에 ToList를 부르는 순간 전부 올라옵니다.

C# code

// 청크 스트리밍
using System;
using System.Collections.Generic;

var tags = new HashSet<string> { "cs", "dotnet", "rust" };
Console.WriteLine(tags.Contains("dotnet"));
알아두면 좋은 점

yield return으로 조각을 흘려보내면 호출부가 처리한 만큼만 메모리에 남아, 입력 크기와 무관하게 사용량이 거의 일정하게 유지됩니다.

자주 하는 실수

지연 시퀀스를 디버거의 결과 창에서 펼쳐 보면 그 자리에서 전체가 열거됩니다. 확인만 했을 뿐인데 실제 처리가 일어나거나 스트림이 소진될 수 있습니다.

13정렬 비용 비교

이 코드는 정렬 없이 두 조각을 이어 붙이기만 합니다. 비교 기반 정렬은 원소 수에 로그를 곱한 만큼 걸리므로, 최댓값 하나만 필요할 때 전체를 정렬하는 것은 낭비입니다. 문자열 정렬은 기본 비교자가 문화권 규칙을 따라 느리고 환경에 따라 순서가 달라질 수 있어, 내부 처리라면 StringComparer.Ordinal을 지정합니다.

C# code

// 정렬 비용 비교
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(13);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

상위 몇 개만 필요하다면 전체 정렬 대신 크기가 고정된 PriorityQueue<TElement, TPriority>를 유지하는 방식으로 훨씬 적은 비교로 끝낼 수 있습니다.

자주 하는 실수

이미 거의 정렬된 데이터라도 OrderBy가 더 빨라지지는 않습니다. 정렬 여부를 아는 쪽에서 판단해 불필요한 정렬 호출 자체를 걷어내야 합니다.

14소유권 또는 참조 재사용

tags는 참조 형식이라 다른 변수에 넣거나 메서드에 넘겨도 복사본 없이 같은 집합을 가리킵니다. 큰 컬렉션을 여러 곳에서 다뤄야 할 때 이 성질 덕에 전달 비용이 거의 들지 않습니다. 반대로 받은 쪽이 원소를 지우면 넘긴 쪽에도 그대로 반영되므로, 외부에 내보낼 때는 읽기 전용 뷰로 감싸는 편이 안전합니다.

C# code

// 소유권 또는 참조 재사용
using System;
using System.Collections.Generic;

var tags = new HashSet<string> { "cs", "dotnet", "rust" };
Console.WriteLine(tags.Contains("dotnet"));
알아두면 좋은 점

IReadOnlyCollection으로 내보내면 인터페이스에는 변경 메서드가 없지만, 받은 쪽이 원래 타입으로 캐스팅하면 여전히 내용을 고칠 수 있습니다.

자주 하는 실수

같은 컬렉션을 여러 곳에서 참조하면 누가 언제 고쳤는지 추적하기 어렵습니다. 순회 중 다른 곳에서 원소가 추가되면 예외가 나는데 원인 지점을 찾기 힘듭니다.

15페이지네이션 슬라이스

번호를 붙여 문자열을 만드는 코드로 목록을 자르는 부분은 없습니다. 페이지를 나눌 때는 Skip과 Take로 필요한 구간만 가져오되 원본 전체를 메모리에 올리지 않도록 합니다. 배열이라면 AsSpan으로 자른 구간이 복사 없이 원본을 가리키므로 추가 할당이 생기지 않습니다.

C# code

// 페이지네이션 슬라이스
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("item-").Append(15);
Console.WriteLine(builder.ToString());
알아두면 좋은 점

배열이나 리스트라면 GetRange나 범위 연산자로 원하는 구간을 바로 얻을 수 있어, 앞부분을 하나씩 지나가는 과정을 생략할 수 있습니다.

자주 하는 실수

Skip(n)은 인덱싱이 가능한 컬렉션이 아니면 앞의 n개를 실제로 하나씩 지나갑니다. 뒤쪽 페이지일수록 건너뛰는 비용이 그대로 늘어납니다.

정리하며

  • 루프 안 문자열 연결은 버퍼로 바꾸고, 크기를 알면 컬렉션 용량을 미리 잡습니다
  • 같은 LINQ 시퀀스를 두 번 열거하지 말고 필요하면 한 번만 구체화합니다
  • 포함 여부 판정이 반복되면 리스트 순회를 Set 또는 사전 조회로 교체합니다
  • 경과 시간은 Stopwatch로 재고, 추측 대신 측정값을 근거로 고칩니다

더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.