C#에서 프로그램 코드를 전개하는 방법은 상대적으로 다른 언어에 비해 자유도가 높은 편입니다. 그렇지만 이런 기능들을 잘 모를 경우 코드 품질이 낮아질 수도 있고, 이해하기 어려운 코드가 되기 쉽습니다. 이러한 문제점을 극복할 수 있는 실용적 코드 작성 팁 몇 가지를 공유해보도록 하겠습니다.
양보하기 어려운 변수 작명을 만났다면?
코딩을 하다보면 그런 경우가 있습니다. 밖으로 드러내는 것이든, 안에서 사용하는 것이든 코드의 의도를 정확히 설명하기 위해서 양보하기 어려운 변수 작명을 고수해야 할 때가 있습니다. 이럴 때에는 고민하지 말고, 변수명 앞에 @ 기호를 지정해주기만 하면 됩니다. C#의 주요 키워드들 (상황에 따라 예약되는 키워드는 이 문제를 만날 가능성이 적습니다.) 상당수를 이 방법을 사용하여 약간 바꾸어 변수 작명으로 채용하는 것이 얼마든지 가능합니다.
string
@abstract = string.Empty, @as = string.Empty, @base = string.Empty, @bool = string.Empty,
@break = string.Empty, @byte = string.Empty, @case = string.Empty, @catch = string.Empty,
@char = string.Empty, @checked = string.Empty, @class = string.Empty, @const = string.Empty,
@continue = string.Empty, @decimal = string.Empty, @default = string.Empty, @delegate = string.Empty,
@do = string.Empty, @double = string.Empty, @else = string.Empty, @enum = string.Empty,
@event = string.Empty, @explicit = string.Empty, @extern = string.Empty, @false = string.Empty,
@finally = string.Empty, @fixed = string.Empty, @float = string.Empty, @for = string.Empty,
@foreach = string.Empty, @goto = string.Empty, @if = string.Empty, @implicit = string.Empty,
@in = string.Empty, @int = string.Empty, @interface = string.Empty, @internal = string.Empty,
@is = string.Empty, @lock = string.Empty, @long = string.Empty, @namespace = string.Empty,
@new = string.Empty, @null = string.Empty, @object = string.Empty, @operator = string.Empty,
@out = string.Empty, @override = string.Empty, @params = string.Empty, @private = string.Empty,
@protected = string.Empty, @public = string.Empty, @readonly = string.Empty, @ref = string.Empty,
@return = string.Empty, @sbyte = string.Empty, @sealed = string.Empty, @short = string.Empty,
@sizeof = string.Empty, @stackalloc = string.Empty, @static = string.Empty, @string = string.Empty,
@struct = string.Empty, @switch = string.Empty, @this = string.Empty, @throw = string.Empty,
@true = string.Empty, @try = string.Empty, @typeof = string.Empty, @uint = string.Empty,
@ulong = string.Empty, @unchecked = string.Empty, @unsafe = string.Empty, @ushort = string.Empty,
@using = string.Empty, @virtual = string.Empty, @void = string.Empty, @volatile = string.Empty,
@while = string.Empty, @__arglist = string.Empty, @__refvalue = string.Empty, @__makeref = string.Empty,
@__reftype = string.Empty;
위의 코드를 컴파일하였을 때 사용하지 않는 변수라는 경고를 제외하고 컴파일에는 이상이 없음을 확인할 수 있습니다.
String.Join 메서드와 같이 시작과 끝에 구분 기호 (Delimiter)가 붙지 않는 문자열 더하기를 수행하는 방법
간혹 그런 경우가 있습니다. 기존 컬렉션으로부터 새로운 컬렉션을 만들면서 시작이나 끝에는 구분자 기호나 원소를 붙이지 않고 중간에만 원하는 내용을 삽입하고 싶을 때가 있는데, 이런 경우 인덱스를 사용하려고 하거나 굳이 배열로 변환하려는 노력을 하게 될 수 있는데, 이는 별로 바람직하지 않습니다. 대신, IEnumerator 인터페이스와 if 문 한번, while 문 한 번으로 나누어 반복문을 써주기만 하면 쉽게 문제가 해결됩니다. 참고로, C#의 foreach 문은 IEnumerator 인터페이스에 대한 포장입니다.
String.Join 메서드와 같은 기능을 하는 메서드를 만들기 위하여, 아래와 같이 코드를 작성할 수 있을 것입니다.
static string Join<T>(string delim, IEnumerable<T> cols)
{
StringBuilder buffer = new StringBuilder();
IEnumerator<T> @enum = cols.GetEnumerator();
if (@enum.MoveNext())
buffer.Append(@enum.Current);
while (@enum.MoveNext())
{
buffer.Append(delim);
buffer.Append(@enum.Current);
}
return buffer.ToString();
}
위의 메서드를 이용하여 문자열의 각 문자들 사이에 쉼표를 붙이는 것을 쉽게 처리할 수 있습니다.
string modified = Join<char>(", ", "Hello guys!");
Console.WriteLine(modified);
H, e, l, l, o, , g, u, y, s, !
현재 컴퓨터를 기준으로 언제나 유일한 값을 빠르게 만들어내는 방법
완벽한 의미에서의 유일성은 상당히 많은 Factor를 반영해야만 그 성격을 보장할 수 있습니다. 그러나, 대개의 경우 지구상에서 유일한 값을 만들어내는것 보다는, 현재 실행 중인 컴퓨터나 데이터베이스를 기준으로 유일한 값을 만들어내는 것 정도만으로도 충분히 목표를 달성할 수 있습니다. 이럴 경우에도 매번 GUID를 생성하거나, 데이터베이스의 Identity Seed를 사용하는 것은 비용이 많이 들고, 특히 데이터베이스의 Identity Seed는 데이터베이스마다 커스터마이징 정도의 차이가 있지만 대개는 생성된 값을 클라이언트 측에서 확인하기 어렵기 때문에 Round Trip을 유발합니다.
지금 소개하는 방법은 이러한 문제점을 극복하면서도 매우 빠른 실행 속도를 보장하는 유일 값 생성 방법입니다. 바로, 현재 시스템의 Tick Count를 그대로 이용하는 방법입니다. Tick Count는 100 나노초 단위이므로 일정한 수준에서의 유일성을 보장하기에는 충분한 밀도가 됩니다. 그리고 생성하는 값의 데이터 형식이 64비트 정수이므로 범위 또한 충분히 넓습니다.
long uniqueVal = DateTime.UtcNow.Ticks;
위와 같이 값을 얻어올 수 있고, 위의 값을 데이터베이스에 레코드를 추가할 때 힌트용으로 사용하는 열에 지정하면 삽입 즉시 조회할 수 있는 고유한 값이 되므로 프로그램 로직 개선에 많은 도움이 됩니다.
조건문의 분기를 임의로 결정하도록 만드는 방법
Modular Operator (%)의 기능과 특징을 아신다면 당연하게 받아들일 수 있는 내용이지만, 이런 특이한 상황에 대해서 유용하게 쓰일 수 있습니다. switch나 if/else 등의 조건문의 분기 자체를 임의 결정할 수 있도록 시뮬레이션해야 하는 상황에서 난수 값이 구체적으로 어떤지를 검색하거나 값을 한정하기 위해서 제약하는 것보다 더 손쉽고 이해하기 편한 시뮬레이션 방식을 % 연산자를 이용하여 쉽게 구현할 수 있습니다.
string modified = Join<char>(", ", "Hello guys!");
Random random = new Random();
char x = '\0';
for (int i = 0; i < 100; i++)
{
switch (Char.ToUpperInvariant(modified[random.Next() % modified.Length]))
{
case 'H': x = 'i'; break;
case 'E': x = 'f'; break;
case 'L': x = 'm'; break;
case 'O': x = 'p'; break;
case ' ': x = '?'; break;
case 'G': x = 'h'; break;
case 'U': x = 'v'; break;
case 'Y': x = 'z'; break;
case 'S': x = 't'; break;
case '!': x = '@'; break;
case ',': x = '.'; break;
default: x = ' '; break;
}
Console.Write(x);
}
Console.WriteLine();
위와 같이 % 기호 다음에 오는 operand로 컬렉션의 길이나 배열의 길이를 지정해주면, 배열의 요소를 임의로 고를 수 있어서 활용폭이 더 넓어집니다.
소스 코드에 특수문자나 CJK 문자를 안전하게 기록하고 다른 사람과 공유하는 방법
드문 경우이지만, 주석 이외에 프로그램의 실행에 실제로 영향을 줄 가능성이 있는 문자열이 영어나 숫자, 혹은 ASCII 범위의 문자가 아닐 경우 다른 환경이나 언어 구성에서 소스 코드 파일을 편집한 후 되돌려받았을 때 문자열이 깨지는 일이 자주 있습니다. 지금 이야기하는 방법은 사실 실용적이지는 않지만, 정말 중요하게 지켜야 할 리소스라면 지금 소개하는 방법을 이용하여 번거롭지만 확실하게 문자열 데이터를 지키는 것도 가능하니 한 번 고려해보시는 것도 좋을 것 같습니다.
예를 들어, 중국어 문자열 "我国屈指可数的财阀。" (우리나라 굴지의 재벌)이 소스 코드에 문자열로 저장되어있고 이 문자열을 인코딩 문제로부터 보호하기 위해서, 위의 문자열을 복사하여 LINQPAD에 아래의 인라인 식에 치환하여 넣습니다. (LINQPAD는 http://www.linqpad.net 에서 다운로드합니다.)
String.Join(", ", "paste here".Select(x => "0x" + ((int)x).ToString("X4")))
그러면 다음과 같은 결과가 나타납니다.
0x6211, 0x56FD, 0x5C48, 0x6307, 0x53EF, 0x6570, 0x7684, 0x8D22, 0x9600, 0x3002
이제 위의 내용을 new String(new char[] { 0x6211, 0x56FD, 0x5C48, 0x6307, 0x53EF, 0x6570, 0x7684, 0x8D22, 0x9600, 0x3002
}); 와 같이 바꾸어서 소스 코드에 저장하면 실행 시 원래 문자열로 복원되면서도, 소스 코드 상의 문자열이 훼손될 걱정을 하지 않아도 됩니다. 단, 이 경우 소스 코드의 내용만으로는 실제로 어떤 문자열인지 파악하기 어려워진다는 장점이자 단점이 동시에 발생합니다. 장점으로는, 일종의 난독처리가 이루어진 셈이며, 단점으로는, 관리가 어려워진 셈이기 때문입니다.
조건문을 어떻게 관리하십니까?
조건문을 어떻게 작성하고 관리하는가에 대한 문제는 개인의 취향과 논리에 따라 매우 다양한 패턴이 존재합니다. 그러나 경험 상, 코드가 간결할 수록 유리하다는 것은 보편적으로 통하는 진리입니다. 개인적인 경험으로 유추해볼 때, 코드의 간결함은, 조건문이나 분기가 얼마나 단일 메서드 내에서 잘 관리되고 있는가에 대한 이야기로 바꾸어 말할 수도 있을 것 같습니다.
이런 방침에 따라, C나 C++ 스타일의 언어들은 중첩해서 사용하는 중괄호의 여닫음 횟수가 늘어날수록 복잡도가 크게 증가합니다. C#도 예외는 아닌데, 이런 이유때문에 저는 스스로 조건문이나 코딩 스타일을 나름의 원칙을 정하여 사용하고 있습니다.
우선, 단위 메서드를 작성하기에 앞서서 조건 검사를 할 때에는 부정적인 시나리오부터 먼저 확인합니다. 다음의 예를 들어보도록 하겠습니다.
public int Divide(int a, int b, out int z)
{
z = 0;
if (b != 0)
{
z = a % b;
return a / b;
}
else
{
throw new DivideByZeroException();
}
}
무난한 코드입니다. 하지만, 제가 볼 때에는 중괄호를 여닫을 필요가 없어보이는 코드입니다. 아래와 같이 정리하면 어떨까요?
public int Divide(int a, int b, out int z)
{
z = 0;
if (b == 0)
throw new DivideByZeroException();
z = a % b;
return a / b;
}
요지는 이렇습니다. 이 메서드에서 우려하는 최악의 상황은 사실 매개 변수 b가 0으로 들어오는 경우입니다. 확실히 문제가 있음을 제기해야 한다면 이 경우를 따로 다루어야 하겠지요. 이를 위해서 b가 0으로 지정되었는지를 검사하여 메서드의 시선으로부터 그런 상황을 제거합니다. 그러면 남는 일은 오로지 나눗셈에 의한 나머지와 몫을 구하는 일이 됩니다. (참고로 z = 0을 서두에 지정한 것은 out 매개 변수에 대한 제약 때문에 그렇습니다. 메서드 본문 밖을 return에 의해서이든 throw에 의해서이든 빠져나가기 전에 반드시 out 매개 변수의 값은 초기화를 해야 합니다.)
그리고 중괄호를 많이 열게 될 개연성이 있는 또 다른 유형은 바로 IDisposable 변수를 다루기 위한 using 블럭입니다. 아래의 경우를 살펴보도록 하겠습니다.
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
using (WinFormModule mod = new WinFormModule(args.FirstOrDefault()))
{
using (StandardKernel kern = new StandardKernel(mod))
{
Application.Run(kern.Get<ApplicationContext>());
mod.FormName = "Form3";
Application.Run(kern.Get<ApplicationContext>());
mod.FormName = "Form2";
Application.Run(kern.Get<ApplicationContext>());
}
}
두 번 열 필요가 없어보이는데도 두 번이나 열었습니다. 위의 코드는 아래와 같이 깔끔하게 정리할 수 있습니다.
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
using (WinFormModule mod = new WinFormModule(args.FirstOrDefault()))
using (StandardKernel kern = new StandardKernel(mod))
{
Application.Run(kern.Get<ApplicationContext>());
mod.FormName = "Form3";
Application.Run(kern.Get<ApplicationContext>());
mod.FormName = "Form2";
Application.Run(kern.Get<ApplicationContext>());
}
IDisposable.Dispose 메서드가 항상 모든 것을 앗아가기만 하는 것은 아니다.
직전에서 다룬 using과 IDisposable에 대한 흔한 오해는, IDisposable 형식의 참조를 using 문과 함께 사용할 때에는 반드시 using 문 내부에서만 선언해야 한다는 것입니다. 그러나 이 경우 문제가 발생하는 일이 있습니다. 아래의 경우를 살펴보도록 하지요.
using (MemoryStream memStream = new MemoryStream())
using (FileStream fileStream = File.OpenRead(@"WinFormDI.exe.config"))
{
fileStream.CopyTo(memStream, 64000);
}
// memStream에 들어있는 내용은 어디서 찾을 수 있습니까?
주석 처리한 부분에서 memStream 변수를 접근해야 하는 이유는 간단합니다. 혹시 MemoryStream의 구현 상에 있을지 모르는 버퍼링 (물론 실제로는 그럴리 없습니다만)을 모두 끝내고 실제 스트림에 쓰여진 상태를 확보하고 싶은데, 막상 MemoryStream의 존재 자체를 알 수 없는 외곽 블록에서는 실행이 다 끝나고도 데이터에 접근할 수 없는 우스운 상황이 생깁니다. 위의 코드를 아래와 같이 고치면 의도대로 잘 작동합니다.
MemoryStream memStream;
using (memStream = new MemoryStream())
using (FileStream fileStream = File.OpenRead(@"WinFormDI.exe.config"))
{
fileStream.CopyTo(memStream, 64000);
}
byte[] buffer = memStream.ToArray();
Console.WriteLine(Convert.ToBase64String(buffer));
사실, 위와 같이 memStream 변수를 밖으로 빼내어도 이상이 없습니다.
memStream은 using 블록 밖에서는 당연히 더 이상 데이터를 기록할 수 없도록 파기된 상태입니다. 하지만, 앞에서 이야기했듯이 IDisposable.Dispose 메서드가 모든 것을 소거하지는 않습니다. 즉, MemoryStream 내부의 byte 배열 버퍼는 여전히 유효합니다. 따라서, 그것의 참조를 Dispose 메서드가 불린 이후라도 가져와서 BASE64 인코딩으로 파일 내용을 인코딩하여 문자열로 바꾸려 했던 코드를 잘 실행할 수 있습니다.
바꾸어 말하면, 아래의 코드도 유효합니다.
MemoryStream memStream = new MemoryStream();
using (memStream)
using (FileStream fileStream = File.OpenRead(@"WinFormDI.exe.config"))
{
fileStream.CopyTo(memStream, 64000);
}
byte[] buffer = memStream.ToArray();
Console.WriteLine(Convert.ToBase64String(buffer));
객체의 생성을 using 문 밖에서 처리하고, 사용하고픈 참조를 담고 있는 변수명을 지칭하기만 해도 같은 의미가 됩니다. using 문 밖으로 나가면 당연히 memStream은 Dispose 메서드가 호출된 상태가 됩니다.