프로그래밍
151편
MagicOnion 은 StreamingHub 에서만 자기 몫의 할당을 없앴다
MagicOnion 을 빼고 같은 일을 하는 gRPC 와 비교하면, StreamingHub 왕복은 할당이 같고 Unary 는 호출당 약 1.6 KB 에 페이로드 1바이트마다 1바이트를 더 씁니다. 상자, 마샬러, 풀이 그 차이를 만듭니다.
MagicOnion 의 Unity 패키지는 채널과 허브를 한 곳에서 닫는다
Unity 에디터는 플레이 모드를 꺼도 프로세스가 살아서, 닫지 않은 채널과 허브 수신 루프가 남습니다. MagicOnion 의 Unity 패키지는 모든 채널을 레지스트리 하나에 모아 종료 때 허브, 채널 순서로 닫습니다.
MagicOnion 의 필터는 메서드를 바인딩할 때 한 번 조립된다
필터는 요청마다 불리는 것처럼 보이지만, 사슬과 필터 인스턴스는 MapMagicOnionService 가 메서드를 바인딩할 때 한 번 만들어집니다. 그래서 요청당 할당은 0 이고, 대신 필터 필드와 생성자 주입은 모든 요청이 나눠 씁니다.
MagicOnion 의 하트비트는 상대의 메시지 처리 루프를 잰다
네트워크는 멀쩡한데 하트비트가 연결을 끊습니다. MagicOnion 의 하트비트는 PING 이 아니라 허브 메시지라서, 답이 상대의 처리 루프를 지나야 나오기 때문입니다. 메인 스레드가 멈추거나 서버 요청 큐가 차면 끊깁니다.
MagicOnion 의 그룹 브로드캐스트는 한 번 직렬화하고 연결마다 복사한다
방에 50명이 있어도 room.All.OnMessage 한 번은 직렬화를 한 번만 하고, 받는 연결마다 바이트를 복사해 응답 큐에 넣고 돌아옵니다. 대신 Single 을 돌리면 직렬화가 사람 수만큼이고, 느린 수신자는 알림 없이 끊깁니다.
MagicOnion 의 StreamingHub 클라이언트는 리시버를 연결한 곳의 스레드에서 부른다
같은 허브 연결인데 await 뒤 코드와 리시버가 다른 스레드에서 돕니다. 응답은 기다린 곳의 컨텍스트로, 리시버는 ConnectAsync 때 잡은 컨텍스트로 가고, 컨텍스트가 없으면 리시버가 수신 전체를 막습니다.
MagicOnion 의 StreamingHub 는 한 연결의 호출을 한 줄로 세운다
허브 메서드는 gRPC 메서드가 아니라 Connect 스트림 하나 위의 메시지입니다. 서버는 연결마다 용량 10 짜리 큐와 소비 루프 하나로 호출을 순서대로 처리하고, 이 큐가 차면 수신 루프가 멈춰 교착이 납니다.
MagicOnion 클라이언트는 인터페이스 구현을 Emit 이나 소스 생성기로 만든다
MagicOnionClient.Create 는 인터페이스만 받는데 구현 객체를 돌려줍니다. JIT 에서는 Reflection.Emit, IL2CPP 에서는 소스 생성기가 같은 모양의 클래스를 만들고, 생성기를 켜면 동적 경로는 사라집니다.
MagicOnion 의 Unary 는 요청마다 ServiceContext 와 서비스 인스턴스를 새로 만든다
서비스 코드는 this.Context 로 요청 정보를 읽습니다. MagicOnion 은 호출마다 인스턴스를 받아 Context 속성을 채우므로, 서비스를 싱글턴으로 등록하면 동시 요청 200건 중 199건이 남의 컨텍스트를 봤습니다.
MagicOnion 은 MapMagicOnionService 안에서 인터페이스를 gRPC 메서드로 등록한다
.proto 도 생성 코드도 없는데 /IMathService/SumAsync 가 생깁니다. MapMagicOnionService 가 로드된 어셈블리를 훑어 grpc-dotnet 확장점에 메서드를 넣기 때문이고, 그래서 규칙에 안 맞는 서비스는 경고 없이 빠집니다.
MagicOnion 은 .proto 대신 C# 인터페이스를 스키마로 쓴다
gRPC 는 .proto 와 코드 생성을 요구합니다. MagicOnion 은 서버와 클라이언트가 같은 C# 인터페이스를 컴파일하는 것으로 그 자리를 채우고, 전송은 gRPC(HTTP/2)에 맡깁니다. 대신 이름이 곧 와이어 계약입니다.
MemoryPack 의 byte[] 직렬화는 GC 가 치울 배열을 매번 남기고, 스레드마다 256KB 를 쥔다
MemoryPack 의 byte[] 직렬화는 호출마다 결과 배열을 새로 만들고, 스레드마다 256KB 작업 공간을 둡니다. 큰 결과 배열은 gen2 GC 를 부르고, 작업 공간은 스레드가 사는 동안 남으니 자주 도는 경로는 재사용 버퍼로 보냅니다.
MemoryPack 은 압축 부품만 주고, 압축 경로는 개발자가 직접 짠다
MemoryPack 에는 압축 옵션이 없고 압축기와 멤버 포매터만 있습니다. 압축 경로는 개발자가 짜야 하고, 그때 압축 표시, 압축할 크기, 푸는 크기 상한, 멤버 포매터의 null 을 직접 챙겨야 합니다.
MemoryPack 의 Union 은 받는 쪽이 모르는 구현 타입을 받으면 예외를 던진다
MemoryPack 의 Union 은 인터페이스 필드에 든 구현 타입을 태그 1 B 로 알립니다. 받는 쪽이 모르는 태그와 등록하지 않은 자식 클래스는 예외이고, 양쪽 선언 타입이 어긋나면 예외 없이 빈 객체가 나옵니다.
MemoryPack 은 기본 모드에서 멤버를 더하면 옛 클라이언트가 응답을 읽지 못한다
MemoryPack 기본 모드는 멤버 수만 적어, 멤버를 더한 새 서버의 응답을 옛 클라이언트가 읽지 못합니다. VersionTolerant 로 막지만 타입 변경은 못 잡고, 순환이 생기면 스택 넘침으로 프로세스가 끝납니다.
MemoryPack 은 멤버 순서를 바꾸면 옛 클라이언트와 값이 예외 없이 섞인다
MemoryPack 의 생성 코드는 멤버를 부모부터 선언 순서대로 값만 씁니다. 서버만 순서를 바꾸거나 부모에 멤버를 더하면 옛 클라이언트와 값이 예외 없이 섞이니, 멤버는 끝에만 추가하고 순서 목록 파일을 리뷰합니다.
MemoryPack 의 포매터는 타입마다 static 필드 하나에 등록된다
MemoryPack 은 타입마다 쓸 포매터를 static 필드 하나에 둡니다. 다시 등록하면 기존 포매터를 덮어쓰고, 한 번 실패한 타입은 IsRegistered 가 true 라 등록이 건너뛰어집니다. 유니티와 함께 쓰는 모델은 두 대상으로 빌드합니다.
MemoryPack 의 리더는 조작한 패킷 하나로 서버 프로세스를 끝낼 수 있다
서버가 클라이언트 패킷을 MemoryPack 으로 바로 풀 때 생기는 네 가지 문제와 예방책입니다. 그중 둘은 try/catch 로 잡을 수 없이 프로세스를 끝내므로, 멤버 포매터나 형식 분리로 리더에 닿기 전에 막아야 합니다.
MemoryPackWriter 는 숫자를 바꾸지 않고 메모리 모양 그대로 쓴다
MemoryPackWriter 는 int 200 을 글자로 바꾸거나 줄이지 않고, C# 이 메모리에 들고 있는 C8 00 00 00 을 그대로 옮겨 적습니다. 버퍼에는 모자랄 때와 Flush 때만 알려 빠르지만, writer 를 직접 쓰면 Flush 전까지 버퍼는 0 B 입니다.
MemoryPackSerializer 는 직렬화 도중 다시 부르면 앞부분을 덮어쓴다
패킷을 직렬화하는 도중 포매터 안에서 MemoryPackSerializer.Serialize 를 또 부르면, 스레드마다 하나인 버퍼를 0번부터 다시 써서 패킷 앞부분이 덮입니다. StringBuilder 하나를 두 함수가 같이 쓰는 것과 같고, 받은 writer 에 바로 써야 합니다.
MemoryPack 은 메모리 모양을 그대로 보내서 받는 쪽 타입대로 읽힌다
.NET 서버와 유니티 클라이언트가 같은 메시지를 주고받는데 시각이 어긋나고 튜플 값이 뒤바뀝니다. MemoryPack 바이트에는 타입 표시가 거의 없고, 값이 메모리 모양 그대로 나가서 받는 쪽 타입의 메모리 모양대로 읽히기 때문입니다.
MemoryPack 은 C# 객체를 메모리 그대로 복사해 바이트로 만든다
서버와 클라이언트가 메시지를 주고받으려면 객체를 바이트로 바꿔야 합니다. MemoryPack 은 그 일을 하는 C# 과 Unity 용 라이브러리로, 코드를 소스 생성기가 만들고 값은 변환 없이 메모리 그대로 복사해 JSON 보다 몇 배 빠릅니다.
MessagePack-CSharp 는 워밍업 뒤 결과물만 할당한다
직렬화 한 번이 힙에 남기는 것은 결과 배열 하나, 역직렬화는 만들어지는 객체뿐입니다. 재사용 버퍼면 0 B 입니다. 예외는 첫 호출과, 한 번에 128 KB 넘게 요청하는 큰 원시 배열입니다.
MessagePack-CSharp 의 스트림 리더는 메시지 끝을 건너뛰어 보며 찾는다
한 스트림에 이어 붙은 메시지 사이에는 길이도 구분자도 없습니다. MessagePackStreamReader 는 TrySkip 으로 값 하나의 끝을 할당 없이 찾지만, 조각나 도착하면 매번 처음부터 다시 훑고 크기 한도도 없습니다.
MessagePack-CSharp 의 UntrustedData 는 해시 비교자와 압축 해제 한도만 바꾼다
기본값 TrustedData 에서는 240 KB 요청이 1초를 먹고 4 KB 압축 블록이 100 MB 를 할당시킵니다. UntrustedData 는 이 둘만 막고, 깊이 한도 500 은 기본값에서도 이미 켜져 있습니다.
MessagePack-CSharp 의 Union 봉투는 선언 타입이 인터페이스일 때만 붙는다
MessagePack 바이트에는 타입 이름이 없어 인터페이스 필드를 되읽을 수 없습니다. Union 은 선언 타입에만 두 칸 봉투를 씌우고, 등록 안 된 타입은 예외 없이 nil 이 됩니다. Typeless 는 스트림이 타입을 고릅니다.
MessagePack-CSharp v3 는 포매터를 컴파일할 때 C# 으로 만든다
런타임 IL 생성은 첫 호출이 느리고 NativeAOT 에서 쓸 수 없습니다. v3 는 소스 생성기가 컴파일 때 포매터를 만들지만, List<내 타입> 멤버는 여전히 동적 코드로 떨어져 NativeAOT 에서 실패합니다.
MessagePack-CSharp 는 문자열 키를 문자열로 만들지 않고 8바이트 정수로 비교한다
정수 키는 배열 칸 번호라 키가 바이트에 실리지 않고, 문자열 키는 이름이 값마다 실립니다. 읽을 때 문자열 키는 string 을 만들지 않고 8바이트씩 ulong 으로 잘라 상수와 비교해 할당이 0 입니다.
MessagePack-CSharp 는 타입마다 포매터를 정적 필드에 한 번만 정한다
직렬화마다 멤버 타입의 포매터를 묻는데도 조회는 7 ns 안팎입니다. 리졸버 체인을 타입마다 한 번 돌아 정적 제네릭 필드에 박아 두기 때문이고, 아무도 모르면 런타임에 IL 로 만듭니다.
MessagePack-CSharp 의 리더는 정수를 쓴 폭이 아니라 값으로 읽는다
1바이트로 쓴 200 을 int 로 읽으면 되고 sbyte 로 읽으면 OverflowException 입니다. MessagePackReader 는 64비트로 넓혔다가 checked 로 좁히고, 거짓 헤더와 잘린 글자도 할당 전에 막습니다.
MessagePackWriter 는 최악 크기를 먼저 잡고 쓴 만큼만 전진한다
값마다 IBufferWriter 를 부르면 가상 호출이 쌓이고, 문자열은 헤더 폭을 인코딩 전에 모릅니다. MessagePackWriter 는 최악 크기를 먼저 잡고 Span 을 쥔 채 쓴 만큼만 전진해 할당 0 으로 씁니다.
MessagePack-CSharp 의 Serialize 는 스레드마다 둔 64 KB 배열에 먼저 쓴다
byte[] 를 돌려주는 Serialize 는 결과 크기를 모르면서도 결과 배열 하나만 할당합니다. 스레드마다 둔 64 KB 스크래치에 먼저 쓰고 복사하기 때문이고, 포매터 안에서 다시 부르면 그 배열이 덮입니다.
MessagePack 포맷은 첫 바이트 하나로 종류와 길이를 말한다
MessagePack 바이트에는 전체 길이도 필드 이름도 없습니다. 첫 바이트 하나가 종류와 길이를 함께 말하고, 작은 정수와 짧은 문자열은 그 한 바이트로 끝납니다. DateTime 은 Kind 를 잃습니다.
MessagePack-CSharp 는 필드 이름 대신 번호로 객체를 바이트로 바꾼다
JSON 은 객체마다 필드 이름을 싣고 숫자를 글자로 바꿉니다. MessagePack-CSharp 는 [Key] 번호로 칸을 정하고 타입마다 전용 포매터를 두어, 같은 객체를 23 B 대신 8 B 로 두 배 안팎 빠르게 씁니다.
안 보이는 것을 안 그리는 값도 든다
앞의 여섯 편은 무엇을 판정할지 줄였습니다. 컬링은 무엇을 그릴지 줄입니다. 절두체 판정을 하나씩 할 때와 묶어서 할 때, 구로 먼저 거를 때의 손익, LOD 가 덜컹거리는 횟수를 실측으로 봅니다.
벽은 막고 문은 통과시킨다
앞의 다섯 편은 물체를 추렸습니다. 포털 컬링은 보이는 영역 자체를 잘라 나갑니다. 문을 지날 때마다 시야가 얼마나 좁아지는지, BSP(binary space partitioning)가 왜 축에 갇히지 않는지 실측으로 봅니다.
움직이는 것들에는 트리를 짓지 않는다
앞의 네 편은 구조를 지어 두고 질의를 던졌습니다. 스윕 앤 프룬(sweep and prune)은 한 축으로 줄만 세워 둡니다. 프레임마다 순서가 거의 안 바뀐다는 성질 하나로 어디까지 갈 수 있는지, 대신 무엇을 내주는지 실측으로 봅니다.
공간이 아니라 물체를 나눈다
격자도 쿼드트리도 KD 트리도 공간을 잘라 왔습니다. BVH(bounding volume hierarchy)는 물체를 둘로 갈라 각 무리를 상자로 감쌉니다. 크기가 있는 물체가 왜 여기에만 온전히 담기는지, 상자끼리 겹쳐도 괜찮은 이유를 실측으로 봅니다.
가운데가 아니라 중앙값에서 자른다
쿼드트리는 공간의 한가운데를 자르고 KD 트리(k-d tree)는 점들의 중앙값을 자릅니다. 트리 모양이 분포에서 풀려나는 대신 무엇을 내주는지, 가지치기가 실제로 몇 개를 걷어 내는지 실측으로 봅니다.
빈 곳은 큰 칸 하나로 남긴다
밀도가 고르지 않으면 칸 크기를 하나로 정할 수 없습니다. 붐비는 칸만 넷으로 쪼개는 쿼드트리(quadtree)의 동작과, 격자보다 나은 자리와 나쁜 자리를 실측으로 갈라 봅니다.
좌표를 칸으로, 칸을 배열로
균일 격자를 실제로 만들 때 막히는 자리들입니다. 음수 좌표에서 칸 번호가 어긋나는 이유, 맵 밖을 다루는 공간 해시(spatial hash), 같은 쌍을 두 번 보지 않는 법, 칸마다 리스트를 두지 않고 배열 세 개로 짓는 법.
UDP 는 아무것도 안 해 준다. 그래서 골라 살 수 있다
TCP 의 보장은 통째로 사거나 통째로 안 사거나였습니다. UDP 는 아무것도 안 해 주는 대신, 필요한 보장만 골라서 직접 얹을 수 있게 합니다. RUDP 가 무엇을 얹고 무엇을 빠뜨리기 쉬운지 봅니다.
TCP 는 무엇을 보장하려고 무엇을 포기했나
사라진 것을 다시 보내고 뒤집힌 것을 바로 세워 주는 대신, TCP 는 앞의 하나가 늦으면 뒤엣것을 전부 세워 둡니다. 그 보장이 어떻게 만들어지고 무엇을 대가로 내놓는지 봅니다.
지연은 줄일 수 없고 감출 수만 있다
떨어진 두 대에 같은 장면을 그리려면 값이 오가야 하고, 그 시간은 빛의 속도에서 이미 하한이 정해집니다. 지연이 어디서 오는지, 그중 무엇만 우리가 건드릴 수 있는지부터 봅니다.
번역도 공짜가 아니다
페이지 테이블은 메모리에 있습니다. 그러니 표를 보는 것도 메모리 접근입니다. CPU 가 TLB 를 두고 표를 네 단계로 쪼갠 이유와, 그 값이 게임에서 어디에 나타나는지 봅니다.
시간을 재는 것도 공짜가 아니다
시계는 두 개이고 쓰임이 다릅니다. 1밀리초를 자라고 하면 1밀리초 뒤에 깨지도 않습니다. 프레임을 일정하게 내보내려고 할 때 어디서 어긋나는지 봅니다.
커널에 들어갔다 나오는 값
페이지 폴트도 스레드 전환도 파일 읽기도 전부 같은 문을 지납니다. 사용자 모드에서 커널 모드로 넘어가는 그 문이 얼마인지, 그리고 돌아온 뒤가 왜 더 비싼지 봅니다.
프로세스와 스레드, 그리고 갈아 끼우는 값
스레드를 바꾸는 것과 프로세스를 바꾸는 것은 값이 다릅니다. 무엇을 갈아 끼우는지, 갈아 끼운 뒤에 왜 한동안 느린지, 그래서 워커를 몇 개나 만들어야 하는지 봅니다.
램이 느려서 캐시가 생겼다
CPU 는 한 사이클에 일하는데 램은 수백 사이클 걸립니다. 그 사이를 메우려고 생긴 것이 캐시이고, 캐시가 기대는 것이 지역성입니다. 계층이 몇 단이고 각 단이 얼마인지부터 봅니다.
내비메시는 바닥이 아니라 설 수 있는 자리다
유니티가 내비메시를 구울 때 실제로 하는 일과, 런타임에 NavMeshAgent 가 하는 일입니다. 보켈로 덮고 반지름만큼 물러나는 과정, 폴리곤 통로와 코너, 카빙과 링크가 치르는 값.
통로 안에서 줄을 당긴다
길찾기가 내놓은 계단 경로와 폴리곤 통로를 사람이 걷는 선으로 펴는 방법입니다. 보이는 점끼리 잇는 스트링 풀링(String Pulling)과, 통로 안 최단을 한 번에 뽑는 깔때기 알고리즘(Funnel Algorithm).
지도 한 장을 모두가 읽는다
유닛 수백 마리가 같은 곳으로 갈 때 각자 길을 찾는 대신, 목표에서 거꾸로 채운 지도를 한 장 만들어 모두가 읽는 방법입니다. 흐름 필드(Flow Field)의 세 겹 구조와 손익분기점, 목표가 바뀔 때 치르는 값.
칸이 아니라 구역 사이를 먼저 잇는다
맵이 커지면 한 칸씩 훑는 길찾기는 넓이만큼 헤맵니다. 맵을 구역으로 나누고 구역 사이만 미리 이어 두는 계층형 길찾기(HPA*, Hierarchical Pathfinding A*)의 구조와, 전처리·경로 품질·구역 크기의 거래.
건너뛰어도 되는 칸은 건너뛴다
균일한 격자에서는 같은 비용의 길이 수십 개라서 탐색이 그만큼 넓어집니다. 그중 하나만 보게 만드는 점프 포인트 서치(JPS, Jump Point Search)의 규칙과, 이 방법이 통하지 않는 자리.
음수가 있거나 모든 쌍이 필요할 때
비용이 가장 적은 후보부터 확정하는 방식이 안 통하는 두 경우입니다. 음수 간선을 다루는 벨만-포드(Bellman-Ford)와 모든 쌍의 거리를 한 번에 구하는 플로이드-워셜(Floyd-Warshall), 그리고 셋 중 무엇을 고를지.
남은 거리를 짐작하면 덜 헤맨다
목표가 하나일 때 탐색을 그쪽으로 기울이는 A 스타(A*) 알고리즘입니다. 휴리스틱이 지켜야 하는 조건, 과대평가하면 무엇이 깨지는지, 격자에서 무엇을 쓰고 동점은 어떻게 처리하는지.
꺼내는 순서를 큐가 아니라 비용이 정한다
칸마다 이동 비용이 다른 맵에서 최단 경로를 찾는 다익스트라 알고리즘(Dijkstra's algorithm)입니다. 왜 한 번 확정한 것을 다시 안 봐도 되는지, 음수 간선에서 왜 무너지는지, 게임에서 가중치를 어디서 얻는지.
가장 작은 것만 꺼낼 건데 전부 정렬할 필요는 없다
우선순위 큐(priority queue)와 그것을 떠받치는 이진 힙(binary heap) 이야기입니다. 트리처럼 보이지만 배열 하나이고, 넣기와 꺼내기가 왜 둘 다 log n 인지, 유니티에서는 무엇을 쓸 수 있는지 봅니다.
채우기는 어디서 멈출지를 색으로 정한다
매치3의 같은 색 묶기, 페인트 통, 지뢰찾기의 빈 칸 열기가 전부 플러드 필(flood fill) 하나입니다. 이웃을 색으로 정하는 탐색이 어디서 터지고, 네 방향과 여덟 방향이 무엇을 바꾸는지 봅니다.
가까운 곳부터 한 겹씩 퍼지면 그게 곧 최단 거리다
너비 우선 탐색은 꺼내는 자리를 스택에서 큐로 바꾼 것이 전부입니다. 그 하나가 왜 최단 거리를 보장하는지, 거리와 부모를 어떻게 기록해 경로를 되짚는지, 그리고 칸마다 비용이 달라지면 왜 무너지는지.
한 갈래를 끝까지 가 보고 막히면 되돌아온다
깊이 우선 탐색이 실제로 어떻게 도는지 봅니다. 재귀와 스택이 왜 같은 코드인지, 방문 표시를 어디서 찍어야 하는지, 그리고 던전 연결 검사와 미로 생성에서 이 순서가 왜 필요한지.
1,000마리에서 멀쩡하던 코드가 10,000마리에서 죽는다
초를 재는 대신 입력이 커질 때 연산이 몇 번으로 늘어나는지를 셉니다. Big-O 를 읽는 법, 최악과 평균이 갈리는 자리, 그리고 기울기가 좋은 쪽이 지는 구간까지.
타일맵도 스킬 트리도 결국 같은 모양이다
점과 선으로 보면 길찾기, 스킬 선행 조건, 던전 방 연결이 한 문제가 됩니다. 그래프를 인접 행렬과 인접 리스트 중 무엇으로 저장할지, 격자는 왜 저장하지 않아도 되는지 봅니다.
꼭짓점은 양 끝이 아니라 두 점을 비교해서 찾는다
올라갔다 내려오는 곡선의 최고점을 찾는 문제입니다. 크다 작다로는 방향을 알 수 없어서, 가운데 두 점을 견줘 삼분의 일씩 버립니다. 그 전제가 깨지는 자리까지.
한 바퀴 돌아간 배열에서도 반쪽은 정렬돼 있다
링 버퍼처럼 어느 지점에서 끊긴 정렬 배열에서 값을 찾습니다. 가운데를 보면 두 반쪽 중 하나는 반드시 정렬돼 있고, 그 사실 하나로 절반을 버릴 수 있습니다.
배열이 아니라 답을 이분 탐색한다
찾는 대상이 배열의 값이 아니라 설정값일 때도 반으로 자를 수 있습니다. 판정이 단조롭기만 하면 됩니다. 그 전제가 깨지는 자리와 실수 범위를 다루는 법까지.
끝이 어딘지 모르는 배열에서 찾기
길이를 알려주지 않는 정렬된 데이터에서 값을 찾는 지수 탐색(exponential search)입니다. 경계를 두 배씩 넓혀 구간부터 만든 다음 이진 탐색으로 넘기는 방법과, 그 비용이 왜 전체 크기와 무관한지.
찾았는데 멈추면 안 되는 이유
정렬된 배열에 같은 값이 여러 개일 때 첫 자리를 찾는 문제입니다. 일반 이진 탐색이 왜 모자란지, 찾은 뒤 왼쪽으로 훑으면 왜 선형이 되는지, 그리고 한 줄만 바꾸면 그게 어떻게 사라지는지.
반으로 자르면 백만 칸이 스무 번에 끝난다
이진 탐색의 기본형입니다. 구간을 닫힌 것으로 잡을지 열린 것으로 잡을지, 언제 멈출지, 그리고 무한 루프와 오버플로가 나는 자리를 봅니다.
취소는 부탁이지 명령이 아니다
스레드를 강제로 죽이는 방법은 없습니다. 대신 "이제 그만해 주세요" 를 전달하고 받는 쪽이 협조합니다.
자물쇠가 없어도 데드락은 걸린다
화면이 얼어붙었는데 예외도 로그도 없습니다. lock 은 한 줄도 없습니다. .Result 한 줄이면 충분합니다.
await 는 기다림을 넘길 뿐 일을 넘기지 않는다
무거운 계산에 async 를 붙였는데 화면이 그대로 멈춥니다. 안에 await 를 하나 넣어도 마찬가지입니다. 재보면 이유가 보입니다.
async 는 스레드를 만들지 않는다
스레드가 여럿일 때 생기는 문제와 async 는 다른 이야기입니다.
끝이 없는 큐는 메모리가 대신 끝을 낸다
넣는 쪽이 꺼내는 쪽보다 빠르면 큐가 자랍니다. 상한을 안 정하면 남은 메모리가 상한이 됩니다.
소켓에 둘이 동시에 쓰면 프레임이 섞인다
자물쇠로 못 막는 자리가 있습니다. 기다리게 하는 대신 줄을 세워 한 명만 쓰게 만듭니다.
false sharing 은 공유한 적이 없어서 false 다
스레드마다 자기 카운터를 줬는데 병렬화가 오히려 느려집니다. 락도 없고 겹쳐 쓴 값도 없습니다. 두 카운터가 같은 64바이트 캐시 라인에 앉아 있어서 그렇습니다. false sharing 이 무엇이고 어떻게 확인하고 어떻게 고치는지를 직접 재본 숫자로 봅니다.
한 스레드만 만지면 잠글 것이 없다
자물쇠는 부딪혔을 때 처리하는 도구입니다. 애초에 안 부딪히게 짜면 자물쇠가 필요 없습니다.
내가 처음인지 물어보려면 CompareExchange 다
lock 은 들어가도 되는지만 알려 줍니다. 딱 한 번만 일어나야 하는 일에는 다른 도구가 필요합니다.
조건 변수는 락을 놓고 기다렸다가 다시 잡는다
락을 쥔 채로 조건을 기다리면 아무도 그 조건을 바꿔 줄 수 없습니다. 조건 변수는 락을 놓는 것과 기다리는 것을 한 번에 합니다. Monitor.Wait 과 Pulse 가 그것이고, if 로 검사하면 100번 중 100번 깨집니다.
lock 은 순서를 만들고 대가를 받는다
여러 줄을 묶어 지키는 유일한 방법인데, 걸어 놓고 잘못 쓰면 프로그램이 멈춰 섭니다.
count++ 는 한 번에 일어나지 않는다
한 줄짜리 코드가 기계에서는 세 걸음입니다. 그 사이에 남이 끼어들면 더한 값이 사라집니다.
페이지 폴트는 오류가 아니지만 프레임은 흔든다
가상 주소가 물리 주소로 번역되는 과정과, 그 번역이 빗나갔을 때 OS 가 하는 일. 그 멈춤이 16.6밀리초짜리 프레임 예산에서 얼마나 큰지까지 봅니다.
가상 메모리는 램이 모자라서 나왔지만 남은 이유는 따로 있다
오버레이를 손으로 짜던 시절부터 1962년 아틀라스까지. 메모리가 모자라서 만든 장치가 왜 램이 남아도는 지금까지 모든 OS 의 바닥에 남아 있는지 봅니다.
GC 가 있는데도 메모리는 샌다
치우는 사람이 있어도 우리가 안 놓으면 안 치워집니다. 가장 흔한 범인은 이벤트입니다.
소멸자는 객체를 한 번 더 살린다
안전망처럼 보이는데 언제 불릴지도 불리기는 할지도 정할 수 없습니다. 붙이면 오히려 늦게 치워집니다.
GC 가 치우지 못하는 것이 있다
힙은 GC 가 알아서 치웁니다. 파일 핸들과 소켓은 아닙니다. using 이 있는 이유입니다.
GC 는 한가할 때 도는 것이 아니다
자리가 모자랄 때 돕니다. 그래서 게임에서는 프레임 한가운데서 돌고, 그 순간 화면이 한 번 걸립니다.
LINQ 는 새로운 문법이 아니다
확장 메서드와 yield 와 람다 셋을 합친 것입니다. 셋을 알면 동작도 함정도 저절로 설명됩니다.
yield return 은 만들다 말고 멈춘다
배열과 List 와 문자열은 안이 전혀 다른데 같은 foreach 로 돕니다. 무엇을 갖추면 돌 수 있는지 봅니다.
Equals 와 GetHashCode 는 반드시 같이 고친다
방금 넣은 값을 같은 키로 꺼냈는데 없다고 나옵니다. 해시가 먼저 자리를 정하기 때문입니다.
연산자도 메서드다
a + b 의 + 는 결국 메서드 하나입니다. 내가 만든 타입에도 그 메서드를 달 수 있습니다.
확장 메서드는 붙는 것이 아니라 붙어 보이는 것이다
string 을 고칠 수 없는데 string 에 메서드를 하나 늘리는 법. 그리고 그게 진짜 늘어난 게 아닌 이유.
인스펙터는 내 private 필드를 어떻게 아는가
감춘 필드가 화면에 나오고, 문자열로 적은 메서드 이름이 실행됩니다. 실행 중에 코드가 코드를 읽기 때문입니다.
const 는 컴파일할 때, readonly 는 실행할 때 정해진다
둘 다 못 바꾸게 막는데 막히는 시점이 다릅니다. 그 차이가 남의 프로젝트에서 사고를 냅니다.
문자열을 반복문 안에서 이으면 매번 새로 만든다
못 바꾼다는 성질의 대가는 반복 횟수만큼 붙습니다. 그런데 대부분의 자리에서는 신경 쓸 필요가 없습니다.
박싱은 값을 힙으로 보내는 일이다
object 자리에 int 를 넣으면 값이 상자에 담겨 힙으로 갑니다. 그 상자값이 얼마인지 봅니다.
struct 는 값을 담고 class 는 주소를 담는다
int 가 힙에 안 가는 이유, 그리고 힙에 안 가는 타입을 직접 만드는 법.
static 은 객체가 아니라 클래스에 붙는다
처음 쓴 코드가 static void Main 이었는데, 그 static 이 무엇인지는 아무도 설명하지 않았습니다.
값이 없다는 것도 상태다
null 은 0이 아니라 가리키는 대상이 없는 것입니다. 없을 수 있다는 사실을 타입에 적어 두면 컴파일러가 봐 줍니다.
예외는 정상 흐름과 오류 흐름을 갈라 놓는다
매 줄마다 성공했는지 확인하지 않아도 됩니다. 정상 코드는 정상 코드대로 두고 오류 처리는 따로 모읍니다.
이벤트는 일이 생기면 알려 달라는 약속이다
알리는 쪽은 누가 듣는지 모르고, 듣는 쪽은 언제 일어날지 모릅니다. 서로를 몰라도 됩니다.
람다는 이름 없이 그 자리에서 만드는 메서드다
한 번 쓰고 말 동작에 이름을 붙이지 않습니다. 넘기는 자리에 바로 적습니다.
대리자는 메서드를 담는 변수다
값이 아니라 무엇을 할지를 담습니다. 부르는 쪽과 실제 동작을 떼어 놓을 수 있습니다.
프로퍼티는 필드 앞에 세운 창구다
겉보기엔 필드처럼 쓰지만 읽고 쓸 때 코드가 실행됩니다. 캡슐화를 문법으로 만든 것입니다.
foreach 는 번호를 신경 쓰지 않는다
처음부터 끝까지 하나씩 꺼내 줍니다. 인덱스가 필요 없는 반복에서 실수할 자리가 사라집니다.
T 는 여기에 타입이 들어온다는 빈칸이다
object 로 다 받으면 타입 검사가 사라지고 박싱이 일어납니다. 제네릭은 타입을 나중에 정하면서 둘 다 막습니다.
무엇인가면 추상 클래스, 무엇을 할 수 있으면 인터페이스
하나는 공통 코드를 물려주면서 빈칸을 남기고, 하나는 지켜야 할 목록만 적습니다. 문장으로 읽어 보면 답이 나옵니다.
같은 호출인데 객체마다 다르게 동작한다
부모 타입으로 불러도 그 객체가 갈아 끼운 메서드가 실행됩니다. 새 자식을 추가해도 부르는 쪽은 안 고칩니다.
상속은 물려받아 덧붙이는 것이다
겹치는 부분을 부모에 두고 다른 것만 자식에 적습니다. 자식을 부모로 보는 것은 언제나 안전합니다.
안은 감추고 창구만 열어 둔다
필드를 열어 두면 잘못된 값이 들어갈 길도 같이 열립니다. 창구를 좁히면 확인할 기회가 생깁니다.
대입하면 값이 복사되거나 주소가 복사된다
규칙은 하나입니다. 담긴 것이 복사됩니다. 담긴 것이 값이냐 주소냐에 따라 결과가 완전히 달라집니다.
생성자는 태어날 때 딱 한 번 실행된다
만들고 나서 값을 채우면 빠뜨릴 수 있습니다. 만드는 순간 채우게 하면 빠뜨릴 방법이 없습니다.
new 는 힙에 자리를 만들고 주소를 돌려준다
변수 안에 객체가 들어 있는 것이 아닙니다. 객체가 있는 곳의 주소가 들어 있습니다.
클래스는 틀이고 객체는 찍어낸 것이다
관련 있는 데이터와 그 데이터를 다루는 코드를 한 덩어리로 묶습니다. 틀은 하나지만 찍어낸 것은 여러 개입니다.
부른 순서대로 쌓이고 반대로 풀린다
메서드가 자기를 부르면 호출이 겹겹이 쌓입니다. 쌓이는 자리에는 한계가 있고, 짧은 코드가 빠른 코드는 아닙니다.
이름은 같고 투입구만 다르다
Console.WriteLine 에 숫자를 넣어도 문자열을 넣어도 되는 이유입니다. 같은 이름의 메서드가 여러 벌 있습니다.
return 은 값을 돌려주면서 메서드를 끝낸다
두 가지 일을 동시에 합니다. 끝낸다는 쪽을 이용하면 깊게 겹친 if 를 평평하게 펼 수 있습니다.
ref 는 고치러, out 은 채우러 간다
하나는 이미 있는 값을 바꾸러 가고, 하나는 없던 값을 만들어 옵니다. TryParse 가 두 번째 방식입니다.
넘어가는 것은 값의 사본이다
메서드 안에서 매개변수를 바꿔도 밖의 원본은 그대로입니다. 값을 적어 보낸 것이지 변수를 보낸 것이 아닙니다.
메서드 한 줄은 네 부분으로 되어 있다
무엇을 돌려주고, 이름이 무엇이고, 무엇을 받고, 무슨 일을 하는가. 이 넷만 정하면 메서드가 됩니다.
"123" 은 숫자 123 이 아니다
입력으로 받은 것은 언제나 문자열입니다. Parse 는 바꿀 수 없으면 프로그램을 멈추고, TryParse 는 못 바꿨다고 알려주기만 합니다.
같은지 보는 것과 줄 세우는 것은 다른 일이다
== 는 문자열의 내용을 비교합니다. 대소문자를 무시하려면 ToLower 로 맞추는 대신 비교 방식을 넘겨야 하고, 정렬 순서가 필요하면 Compare 를 씁니다.
자르고 찾는 것은 전부 새 문자열이다
Substring 의 둘째 인자는 끝 번호가 아니라 개수입니다. IndexOf 는 못 찾으면 -1 을 돌려줍니다. 두 가지만 알아도 문자열 버그의 대부분이 없어집니다.
적은 그대로가 값이 되는 문자열
따옴표는 문자열의 끝을 알리는 기호라서 안에 그대로 쓸 수 없습니다. 역슬래시로 피하는 방법과, 그마저 필요 없게 만드는 따옴표 세 개를 봅니다.
값은 그대로 두고 모양만 바꾼다
$ 는 문자열 안에 값을 끼워 넣고, 콜론 뒤에 적는 형식은 그 값을 어떻게 보여줄지만 정합니다. 캐스팅과 달리 값 자체는 건드리지 않습니다.
바깥은 행, 안쪽은 열이다
바깥이 한 번 돌 때마다 안쪽은 처음부터 끝까지 전부 돕니다. 별찍기는 줄마다 개수를 세는 문제입니다.
break 는 나가고, continue 는 건너뛴다
하나는 반복문을 끝내고 하나는 이번 회차만 포기합니다. continue 는 반복을 멈추지 않습니다.
for 는 적힌 순서대로 실행되지 않는다
괄호에 세 가지가 나란히 적혀 있지만 셋째는 본문이 끝난 뒤에 실행됩니다. 그래서 본문에서 보는 i 는 아직 증가 전입니다.
do-while 은 일단 한 번은 한다
while 은 조건을 먼저 보고 do-while 은 본문을 먼저 실행합니다. 물어보기 전에 한 번은 해야 하는 일에 씁니다.
모든 반복문은 세 부분으로 되어 있다
어디서 시작하고, 언제까지 돌고, 매번 무엇이 변하는가. 셋 중 하나가 빠지면 영원히 돌거나 한 번도 안 돕니다.
코드에 적히지 않은 약속은 없는 약속이다
case 4 가 목요일이라는 사실이 코드 어디에도 없습니다. const 와 enum 은 그 약속을 코드 안으로 옮기는 문법입니다.
switch 는 조건이 아니라 값을 본다
else if 사슬이 같은 변수를 계속 다시 묻고 있다면 switch 자리입니다. 범위는 if, 목록은 switch입니다.
조건을 쓰는 순서가 곧 우선순위다
else if 는 위에서부터 처음 맞는 하나만 고르고 나머지는 검사조차 하지 않습니다. 그래서 쓰는 순서가 결과를 바꿉니다.
갈림길을 정하는 것은 bool 하나다
괄호 안이 아무리 길어도 결과는 true 아니면 false 하나입니다. 그 한 값이 실행이 지나갈 길을 고릅니다.
문자열은 바꿀 수 없다
문자열은 문자를 나란히 놓은 것입니다. 인덱스가 0부터인 이유부터, 고칠 수 없어서 + 가 매번 새 문자열을 만드는 것까지 봅니다.
문자는 숫자로 저장된다
'A' 가 왜 하필 65인지, 대문자와 소문자가 왜 32 차이인지. 아스키코드에서 유니코드까지 봅니다.
식은 순서대로 줄어든다
컴파일러는 한 줄을 왼쪽부터 읽지 않습니다. 우선순위가 높은 곳부터 값으로 바꿔 하나만 남을 때까지 줄입니다.
연산자는 값을 만든다
+, %, ==, && 같은 기호들이 각각 무엇을 하고 어떤 값을 만드는지 한 번에 봅니다. = 와 == 의 차이부터 ++ 의 전위와 후위까지.
값을 옮기면 무슨 일이 생기나
넓은 칸으로 갈 때는 채우고, 좁은 칸으로 갈 때는 잘라냅니다. (int)는 반올림이 아니라 버림이고, 10 / 3 은 나눗셈 시점에 이미 3입니다.
0.1 은 2진수로 끝나지 않는다
0.1 + 0.2 가 0.3 이 아닌 이유. 실수는 값을 그대로 담지 않고 부호·지수·가수 세 조각으로 나눠 담습니다.
정수는 직선이 아니라 원이다
int.MaxValue 에 1을 더하면 int.MinValue 가 됩니다. 예외도 경고도 없이 값만 바뀝니다. 왜 하필 최솟값인지 그림으로 봅니다.
음수는 메모리에 어떻게 들어가 있나
음수라고 특별한 표시가 붙지 않습니다. 같은 자리에 비트만 다르게 채워질 뿐입니다. 부호 비트와 2의 보수를 그림으로 봅니다.
변수를 선언하면 무슨 일이 일어나는가
변수는 값을 담는 상자가 아니라 이름이 붙은 메모리 칸입니다. 선언·할당·사용이 각각 언제 무엇을 하는지 그림으로 봅니다.
LinkedList 는 손을 잡고 선 줄이다
칸이 붙어 있지 않고 각자 다음 사람의 위치를 들고 있습니다. 끼워 넣기는 싸지고 번호로 찾기는 비싸집니다.
넣고 빼는 순서가 규칙이다
어느 것을 꺼낼지 우리가 고르지 않습니다. 나중에 넣은 것부터 나오거나, 먼저 넣은 것부터 나옵니다.
해시 테이블은 자리를 계산해서 정한다
Dictionary 와 HashSet 이 곧장 찾는 이유입니다. 해시 코드를 버킷 개수로 나눈 나머지가 자리이고, 그 버킷 개수는 언제나 소수입니다. 겹치면 어떻게 되고 줄은 어디까지 길어지는지까지 봅니다.
HashSet 은 중복을 받지 않는다
이미 있는지 확인하는 일을 List 로 하면 전부 훑습니다. Dictionary 에서 값을 떼고 키만 남긴 것이 HashSet 입니다.
Dictionary 는 번호 대신 이름으로 찾는다
이름을 숫자로 바꿔서 자리를 계산합니다. 그래서 몇 개가 들어 있든 찾는 시간이 거의 같습니다.
List 는 칸이 모자라면 알아서 늘린다
속은 그냥 배열입니다. 꽉 차면 더 큰 배열을 만들어 옮기는 일을 대신 해 줄 뿐입니다.
2차원 배열은 행과 열로 찾는다
격자로 보이지만 메모리에는 여전히 한 줄로 놓여 있습니다. 번호 두 개가 한 자리를 가리키는 방식입니다.
배열은 번호가 붙은 칸 한 줄이다
값 하나에 변수 하나씩 만들면 백 개를 다룰 수 없습니다. 배열은 이름 하나에 칸을 여러 개 붙여 번호로 꺼냅니다.