[C# 12와 .NET 8] 4. Debuging과 Testing
code의 재사용을 위한 함수의 작성과 개발동안의 logic error에 대한 debugging, runtime에서의 logging, code의 bug제거와 신뢰성 및 안정성을 높이기 위한 unit test 등은 개발과정에서 매우 중요한 요소로 취급되고 있습니다.
1. 함수 작성
programming에서의 기본적인 원칙은 흔히 DRY불리는 '반복하지 마라'입니다.
programming동안에 같은 구문을 작성하고 또 그것을 반복하고 있다면 이들 구문을 함수로 전환할 필요가 있습니다. 함수는 application전체에서 하나의 작은 작업의 단위를 처리하는 부분으로서 예로 부가세 계산 logic과 같은 것들을 들 수 있으며 이러한 함수는 회계 application의 여러 곳에서 재사용될 수 있습니다.
program에서 함수는 일반적으로 입력과 출력을 가지며 대게 한쪽 끝에서 원재료를 투입하고 다른 쪽에서 제조된 item이 나오는 black box로도 표현됩니다. 일단 생성된 것을 철저하게 debuging하고 test 한다면 향후에 해당 application을 유지관리하는데 상당한 도움이 될 것입니다.
함수는 OOP세계에서 class에 소속될 수 있는데 이때는 이를 Method라고 합니다. C#에서 함수는 Method와 같은 의미로 해석될 수 있습니다.
(1) top-level program과 function
.NET 6부터 console app의 기본 project template에서는 C# 9에서 도입된 top-level program을 사용하고 있습니다.
함수를 작성하면 자동적으로 생성된 Program class와 <Main>$ method에서 이들이 어떻게 작동하는지를 이해하는 것이 중요합니다.
아래 예제에서는 Program.cs에서 class를 import하고 해당 class의 method를 호출하고 있으며 또한 함수 하나를 정의하고 해당 함수를 호출하고 있습니다.
using static System.Console;
WriteLine("Hello, World!");
DoSomething();
void DoSomething()
{
WriteLine("Doing something!");
}
compiler는 자동적으로 <Main>$ 함수와 함께 Program class를 생성하며 위 예제와 같이 입력된 구문과 함수를 <Main>$ method안으로 이동시킨 뒤 함수의 이름을 아래와 같이 바꾸게 됩니다.
using static System.Console;
partial class Program
{
static void <Main>$(String[] args)
{
WriteLine("Hello, World!");
<<Main>$>g__DoSomething|0_0();
void <<Main>$>g__DoSomething|0_0()
{
WriteLine("Doing something!");
}
}
}
위와 같이 compiler가 어떤구문이 어디에 필요한지를 파악할 수 있도록 하기 위해서는 다음과 같이 규칙을 따라야 합니다.
- Import문(using)은 Program.cs file의 상단에 위치해야 합니다.
- <Main>$ function안으로 옮겨져야할 구문은 Program.cs file의 중간에 와야 합니다.
- 함수는 Program.cs file의 하단에 위치해야 하며 이들은 지역 함수가 됩니다.
특히 지역함수의 경우 일부 제한을 가질 수 있으므로 위 마지막 규칙은 기억하고 있어야 합니다.
예제에서 사용된 static이나 partial과 같은 keyword는 추후에 OOP를 다루는 과정에서 자세하게 살펴볼 것입니다.
함수를 정의하기 위한 보다 나은 방법은 분리된 file에서 함수를 정의하고 함수를 Program class에 정적(static) member로서 추가하는 것입니다.
//Function.cs와 같은 별도의 file에서 작성
partial class Program
{
static void DoSomething()
{
Console.WriteLine("Doing something!");
}
}
//Program.cs에서 작성
using static System.Console;
WriteLine("Hello, World!");
DoSomething();
compiler가 Program class를 <Main>$ 함수와 함께 정의할 때 위 구문을 <Main>$ method안으로 옮기게 되고 Program class의 member로서 상기 함수를 병합하여 아래와 같은 결과물을 만들게 됩니다.
using static System.Console;
partial class Program
{
static void <Main>$(String[] args)
{
WriteLine("Hello, World!");
DoSomething();
}
static void DoSomething() // define a function
{
WriteLine("Doing something!");
}
}
Program.cs에서 호출될 모든 함수는 별도의 분리된 file에서 생성하고 partial Program class안에서 이들을 호출하는 방법으로 사용하면 자동적으로 생성된 Program class의 <Main>$ method안에 지역함수로 들어가는 대신 <Main>$ method와 동일한 수준으로 병합될 것입니다.
(2) 구구단
구구단을 공부하기 위해 구구단표를 만들어야 한다고 가정해 보면, 예를 들어 7단의 경우 아래와 같은 구구단을 생성할 수 있습니다.
7 × 1 = 7 7 × 2 = 14 7 × 3 = 21 ... 7 × 9 = 63 |
구구단을 공부하는 수준에 따라서 10이나 12, 심지어 20까지의 곱셈이 들어간 표를 생성할 수도 있을 것입니다. 이전에 우리는 for문에 대해서 배웠고 일반적인 pattern에서 반복작업을 수행하는 데 사용할 수 있음을 알고 있습니다. 이를 통해 7단을 9 곱셈까지 표현하려면 다음과 같이 구현할 수 있습니다.
for (int row = 1; row <= 9; row++)
{
Console.WriteLine($"{row} x 7 = {row * 7}");
}
하지만 위에서 처럼 항상 7단에 9곱셈까지 한정하여 표시하는 대신 이를 모든 숫자에 대한 원하는 만큼의 곱셈으로 표현하는 유연성을 가지기 위해서는 함수를 사용해야 합니다.
● 구구단 함수 만들기
이를 위해 만들어볼 함수는 임의의 숫자에 대한 구구단을 표시하며 임의의 숫자까지(기본값 9)의 곱셈을 표시하도록 할 것입니다.
예제를 위해 csStudy04 solution을 생성하고 WritingFunctions project를 추가합니다. 해당 project에서는 Function.cs file을 추가한 뒤 아래와 같이 partial Program class에 TimesTable이름의 함수를 정의합니다.
partial class Program
{
static void TimesTable(byte number, byte size = 9)
{
Console.WriteLine($"This is the {number} times table with {size} rows:");
for (int row = 1; row <= size; row++)
{
Console.WriteLine($"{number} x {row} = {number * row}");
}
Console.WriteLine();
}
}
해당 예제는 다음의 특징을 가집니다.
- TimesTable은 number이름의 매개변수로 전달된 byte값을 가져야 합니다.
- TimesTable은 선택적으로 size라는 이름의 매개변수로 전달된 byte값을 가져야 합니다. 값이 전달되지 않으면 기본은 12가 됩니다.
- TimesTable은 static method인 <Main>$에서 호출되어야 하므로 static method입니다.
- TimesTable은 호출자에게 값을 반환하지 않으므로 함수의 이름앞에 void keyword를 사용하여 선언될 수 있습니다.
- TimesTable은 전달된 수의 구구단을 출력하기 위해 number와 size를 사용하는 for문을 사용합니다.
Program.cs에서는 기존의 구문을 모두 삭제하고 number 매개변수의 byte값을 전달하여 위 함수를 호출하도록 합니다.
TimesTable(7);
project를 실행하면 다음의 결과를 표시합니다.
이번에는 7단에 20까지의 곱셈을 표시하도록 다음과 같이 함수를 호출합니다.
TimesTable(7, 20);
결과는 다음과 같습니다.
함수가 하나 또는 그 이상의 매개변수를 가지고 있을때 값을 전달하는 것만으로 충분한 의미를 제공하지 못한다면 해당 값과 함께 선택적으로 매개변수의 이름을 다음과 같이 지정할 수 있습니다.
TimesTable(number: 7, size: 20);
위와 같이 TimesTable함수를 호출할때는 0부터 255까지의 byte값을 전달해야 합니다. 매개변수가 byte형식이기 때문인데 따라서 int나, double, string과 같은 형식의 값을 전달하면 다음과 같이 error를 유발하게 됩니다.
(3) 인수와 매개변수
대부분의 개발자가 일상적으로 사용하는 단어로 인수와 매개변수라는 말이 있는데 개발자들 중에서도 이 2개의 용어를 혼동하는 경우가 많습니다. 엄밀히 말하면 이 2개의 용어는 전혀 다르지만 종종 같은 뜻으로 사용되는 경우가 있는 것입니다.
매개변수(parameter)는 함수안에서 정의되는 변수입니다. 예를 들어 Hire 함수 안에서 startDate는 매개변수입니다.
void Hire(DateTime startDate)
{
// implementation
}
method가 호출될때 method의 매개변수로 전달한 data를 인수(argument)라고 합니다. 예를 들어 Hire 함수로 전달한 when이 인수가 됩니다.
DateTime when = new(year: 2022, month: 11, day: 8);
Hire(when);
인수를 전달할때 매개변수의 이름을 지정하고자 한다면 아래와 같이 할 수 있습니다.
DateTime when = new(year: 2022, month: 11, day: 8);
Hire(startDate: when);
결론적으로 Hire 함수를 호출한다고 했을 때 startDate는 매개변수, when은 인수가 됩니다.
Microsoft 공식 문서에서도 'named and optional arguments'와 'named and optional parameters'를 같은 의미로 사용하고 있습니다.
Named and Optional Arguments - C# Programming Guide | Microsoft Learn
이것은 하나의 단일 개체가 context에 따라 매개변수와 인수 둘 다 될 수 있기 때문에 약간 난해할 수 있습니다. 예를 들어 Hire 함수 안에서 startDate 매개변수는 SaveToDatabase처럼 다른 함수로 인수로서 전달될 수 있습니다.
void Hire(DateTime startDate)
{
//Something Do
SaveToDatabase(startDate, employeeRecord);
}
programming에서 가장 어려운것중 하나는 이름을 짓는 것입니다. 아래 예제는 C#에서 가장 중요한 Main 함수로서 'arguments'의 단축형인 args이름의 매개변수를 정의하고 있습니다.
static void Main(String[] args)
{
}
요약하자면 매개변수는 함수로의 입력을 정의하며 인수는 함수가 호출될때 함수로 전달되는 것입니다.
(4) 값을 반환하는 함수
위 함수는 looping과 console로의 출력을 수행하는 함수이지만 어떠한 값도 반환하지 않습니다. 하지만 모든 함수를 그렇게 만들 수는 없는데 예를 들어 매출이나 부가세 계산이 필요하다고 했을 때 Europe에서 부가가치세(VAT) 율은 Switzerland의 8%에서 Hungary의 27% 범위가 될 수 있고 미국에서는 Oregon 0%에서 California의 8.25% 범위까지 될 수 있습니다.
세율은 언제나 바뀔 수 있고 많은 요인에 따라 달라집니다. 예제에서 사용된 값은 정확한 값이 아닙니다.
아래 예제는 세계의 다양한 지역에서 세율을 계산하는 함수로서 Functions.cs에서 CalculateTax라는 이름의 함수를 추가합니다.
static decimal CalculateTax(decimal amount, string twoLetterRegionCode)
{
decimal rate = 0.0M;
switch (twoLetterRegionCode)
{
case "CH": // Switzerland
rate = 0.08M;
break;
case "DK": // Denmark
case "NO": // Norway
rate = 0.25M;
break;
case "GB": // United Kingdom
case "FR": // France
rate = 0.2M;
break;
case "HU": // Hungary
rate = 0.27M;
break;
case "OR": // Oregon
case "AK": // Alaska
case "MT": // Montana
rate = 0.0M;
break;
case "ND": // North Dakota
case "WI": // Wisconsin
case "ME": // Maine
case "VA": // Virginia
rate = 0.05M;
break;
case "CA": // California
rate = 0.0825M;
break;
default: // most US states
rate = 0.06M;
break;
}
return amount * rate;
}
위 예제에서는 다음 사항에 주목해야 합니다.
- CalculateTax는 2개의 입력을 받고 있으며 amount라는 이름의 매개변수는 지출금액이 되고 twoLetterRegionCode이름의 매개변수는 지출이 이루어진 지역이 됩니다.
- CalculateTax는 switch구문을 사용해 계산을 수행하며 10진수로서 지불해야할 sales tax나 VAT를 반환합니다. 따라서 함수이름 이전에 반환값의 data type으로 decimal형식의 값을 받을 수 있도록 해야 합니다.
Program.cs에서는 위에서 만든 CalculateTax함수를 호출하도록 하는데 이때 149와 같은 금액값과 FR 같은 정확한 지역 code를 전달해야 합니다.
decimal taxToPay = CalculateTax(amount: 149, twoLetterRegionCode: "FR");
Console.WriteLine($"You must pay {taxToPay} in tax.");
예제를 실행하면 다음과 같은 결과를 표시하게 됩니다.
위 예제에서 taxToPay는 '{taxToPay:C}'와 같은 방법으로 지역문화권을 통해 통화표시로 서식화할 수 있습니다. 예를 들어 대한민국의 경우 ₩28.9와 같이 표시됩니다.
● 숫자를 기수에서 서수로 변환하기
셈을 위해 사용되는 숫자를 기수라고 하며 1, 2, 3등을 예로 들 수 있습니다. 반면 서수는 순서에 사용되며 첫번째(1st), 두 번째(2nd), 세 번째(3rd)와 같은 것들을 말합니다. 아래 예제는 기수를 서수로 변환하는 함수로서 Function.cs에 CardinalToOrdinal이름으로 정의하였습니다. 이 함수는 기수의 int값을 서수의 문자열값으로 변환하므로 1은 1st, 2는 2nd, 3은 3rd등으로 변환됩니다.
static string CardinalToOrdinal(int number)
{
int lastTwoDigits = number % 100;
switch (lastTwoDigits)
{
case 11:
case 12:
case 13:
return $"{number:N0}th";
default:
int lastDigit = number % 10;
string suffix = lastDigit switch
{
1 => "st",
2 => "nd",
3 => "rd",
_ => "th"
};
return $"{number:N0}{suffix}";
}
}
위의 함수에서는 아래 사항에 주목합니다.
- CardinalToOrdinal은 number라는 이름의 int type매개변수로 하나의 입력을 받으며 string type의 값을 반환합니다.
- switch문은 11, 12, 13의 특정 case를 처리하기 위해 사용되었습니다.
- switch표현식은 그외 다른 모든 case를 처리하는 데 사용되며 마지막 숫자가 1이면 st를, 2면 nd를, 3이면 rd접미사를 사용하고 그 외 다른 숫자라면 th접미사를 사용하도록 합니다.
이번에는 Function.cs에서 RunCardinalToOrdinal이름의 함수를 작성합니다. 해당 함수는 1~150까지 loop를 순회하면서 각 숫자에 대해 CardinalToOrdinal함수를 호출하고 console에 반환된 문자열을 출력합니다. 이때 공백문자를 통해 각 출력을 구분하고 있습니다.
static void RunCardinalToOrdinal()
{
for (int number = 1; number <= 150; number++)
{
Console.Write($"{CardinalToOrdinal(number)} ");
}
Console.WriteLine();
}
Program.cs에서는 기존의 구문을 모두 주석처리하고 RunCardinalToOrdinal method를 아래와 같이 호출합니다.
RunCardinalToOrdinal();
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
(5) 재귀호출을 통한 계승 계산
계승은 시작되는 숫자에서 부터 1 작은 수를 곱하고 다시 그 숫자에 1 작은 수를 곱하여 시작수가 1이 될 때까지 계산합니다. 따라서 5의 계승은 5 Χ 4 Χ 3 Χ 1이 되므로 120입니다.
계승 함수는 음수가 아닌 아래와 같이 1, 2, 3, 4와 같은 정수를 위해서만 정의됩니다.
0! = 1 n! = n × (n − 1)!, for n ∈ { 1, 2, 3, ... } |
계승은 '5!'와 같이 쓰며 여기서 느낌표를 계승이라고 표현하므로 '5! = 120은 5의 계승은 120이다.'라고 할 수 있습니다.
다음 예제는 Factorial함수로서 매개변수로 전달된 int값에 대한 계승을 계산할 것입니다. 여기서는 '재귀'라고 하는 방법을 사용했는데 이는 직접적이든 간접적이든 구현된 범위 안에서 자신을 호출하는 함수를 의미합니다.
static int Factorial(int number)
{
if (number < 0)
throw new ArgumentException(message: "factorial 함수는 정수만 사용할 수 있습니다. { number }", paramName: nameof(number));
else if (number == 0)
return 1;
else
return number * Factorial(number - 1);
}
전과 마찬가지로 위의 예제에서도 몇몇 주목할만한 부분이 존재합니다.
- 매개변수 number가 음수가 된다면 Factorial은 예외를 발생시킵니다.
- 매개변수 number가 0이라면 1을 반환합니다.
- 매개변수 number가 그외 다른 case가 되는 1보다 크다면 Factorial은 number보다 1적은 수를 전달하여 스스로를 호출함으로써 number에 대한 곱셈을 수행합니다.(재귀함수)
재귀는 어떤면에서 현명한 방법이 될 수 있지만 잘못하면 너무 많은 함수를 호출하면 함수가 호출될 때마다 memory를 사용하게 되어 결국에는 overflow를 일으킬 수 있는 등의 문제를 발생시킬 수 있습니다. 반복은 간결함이 떨어질 수 있지만 C#과 같은 언어에서 더 실용적인 해결책이 될 수 있습니다. 이와 관련하여 아래 link를 참고하시기 바랍니다.
Recursion (computer science) - Wikipedia
Functions.cs에서 아래와 같이 RunFactorial이름의 함수를 추가합니다. 이 함수는 1부터 15까지의 숫자에 대한 factorial을 표시하기 위해 for문을 사용하며 N0 code를 사용해 소수점 0자리와 함께 , 로 천 단위 숫자를 분리하는 서식화를 적용하고 있습니다.
static void RunFactorial()
{
for (int i = 1; i <= 15; i++)
{
Console.WriteLine($"{i}! = {Factorial(i):N0}");
}
}
RunCardinalToOrdinal method의 호출부분을 주석처리하고 RunFactorial method를 호출하도록 한 뒤 예제를 실행하면 다음과 같은 결과를 표시하게 됩니다.
위의 결과를 통해서는 분명해 보이지 않을 수 있지만 13이상의 계승은 너무 큰 숫자로 int type을 overflow 합니다. 12! 는 479,001,600인데 이는 5억 가까이 됩니다. int변수로 저장가능한 최대 양수값은 20만입니다. 13! 은 6,227,020,800이므로 60억이며 이는 32bit integer로 저장될 때 어떠한 오류도 표시하지 않고 overflow로 처리됩니다.
물론 overflow가 발생하지 않도록 13!이상에서 32bit int대신 64bit long을 사용할 수 있지만 그런 조치를 함에도 불구하고 빠르게 overflow의 한계에 다시 도달하게 될 것입니다.
여기서 핵심은 숫자가 overflow될 수 있다는 것을 이해하고 직접 확인하는 것입니다. 12 이상의 factorial을 어떻게 계산할지를 공부하고자 하는 것이 아닙니다. 아래와 같이 Factorial함수를 수정하여 overflow를 확인할 수 있도록 합니다.
static int Factorial(int number)
{
if (number < 0)
throw new ArgumentException(message: $"factorial 함수는 정수만 사용할 수 있습니다. { number }", paramName: nameof(number));
else if (number == 0)
return 1;
else
{
checked // overflow check
{
return number * Factorial(number - 1);
}
}
}
RunFactorial 함수도 for에서 시작되는 수를 -2로 변경하여 overflow와 Factorial method를 호출할 때의 예외를 처리될 수 있도록 합니다.
static void RunFactorial()
{
for (int i = -2; i <= 15; i++)
{
try
{
Console.WriteLine($"{i}! = {Factorial(i):N0}");
}
catch (OverflowException)
{
Console.WriteLine($"{i}! 32bit integer에서는 처리할 수 없는 수입니다.");
}
catch (Exception ex)
{
Console.WriteLine($"{i}! throws {ex.GetType()}: {ex.Message}");
}
}
}
예제를 실행하면 결과는 다음과 같을 것입니다.
(6) XML주석을 통해 함수 설명하기
CardinalToOrdinal과 같은 함수를 호출할때는 보통 code editor는 기본적인 정보를 표시하는 tooltip을 보여주게 됩니다.
위와 같은 상황에서 사용자에게 보여질 정보는 XML주석을 통해 추가될 수 있습니다. CardinalToOrdinal함수 위에서 slashe문자 3개를 연속으로 입력합니다. 그러면 해당 함수가 number라는 하나의 매개변수를 갖는다고 인식하고 거기에 맞는 XML주석의 틀이 아래와 같이 표시될 것입니다.
/// <summary>
///
/// </summary>
/// <param name="number"></param>
/// <returns></returns>
해당 주석틀 안에 CardinalToOrdinal함수에 관한 적절한 정보를 입력하면 되는데 summary는 함수에 대한 대략적인 설명을, param에는 해당 매개변수에 대한 설명을, returns에는 반환값에 대한 설명을 입력하면 됩니다.
/// <summary>
/// 32-bit integer값을 전달하면 이에 맞는 서수로 변환하여 그 결과를 반환합니다.
/// </summary>
/// <param name="number">1, 2, 3과 같은 정수값을 입력합니다.</param>
/// <returns>1st, 2nd, 3rd와 같은 서수로 변환된 값입니다.</returns>
위와 같이 설명을 작성한 후 다시 함수의 호출 부분에서 mouse를 대면 다음과 같이 입력한 정보를 표시하게 됩니다.
이 기능은 Sandcastle처럼 주석을 문서로 변환하는 도구와 함께 사용되도록 설계된 것으로 아래 link를 통해 Sandcasle에 대한 좀 더 자세한 정보를 확인할 수 있습니다.
GitHub - EWSoftware/SHFB: Sandcastle Help File Builder (SHFB).
code를 입력하거나 함수이름에 mouse를 올려둘때 나타나는 tooltip은 보조적 기능에 해당합니다.
지역함수의 경우 이들이 선언된 외부에서는 사용될 수 없기 때문에 XML 주석을 지원하지 않습니다. 따라서 이들에 대한 문서화를 생성하는 것은 의미가 없으며 Visual Studio 2022나 Visual Studio Code도 인식하지 못하므로 tooltip조차 사용되지 않습니다.
지역 함수를 제외하고 code상의 모든 함수에 XML주석을 추가하기를 권장합니다.
(7) 함수에서 lambda사용하기
F#은 Microsoft의 강력한 Type의 함수형 programming언어로서 C#과 같이 .NET하에서 실행되기 위해 IL로 compile 됩니다. 이러한 함수형 언어는 계산 system이 함수에 기반한 lambda계산에서 진화한 것인데 code는 일반적인 절차형 언어의 그것보다는 수학적 함수에 가깝습니다.
함수형 언어에서 주목해야할 중요한 특징으로는 다음과 같은 것들이 있습니다.
- 단위화(Modularty) : C#에서 함수를 정의할 때와 동일한 이점이 함수형 언어에도 적용됩니다. 이를 통해 크고 복잡한 code는 더 작은 조각으로 나누어질 수 있습니다.
- 불변성(Immutability) : 함수안에서 모든 변수는 바뀔 수 없습니다. 대신 기존에 존재하는 것으로부터 새로운 data값이 생성될 수 있습니다. 이러한 방식은 bug를 줄이는데도 도움이 됩니다.
- 관리성(Maintainability) : Code는 깔끔하고 명확합니다.(수학적 성향을 가진 개발자들을 위해!)
C# 6 이후 Microsoft는 더욱 함수적인 접근법을 지원하기 위해 언어에 기능추가를 시도해 왔습니다. 예를 들어 C# 7에서의 tuple과 pattern matching, C# 8에서의 non-null 참조 type, 그리고 C# 9에서 pattern matching의 향상과 record 즉, 불변개체등을 들 수 있습니다.
C# 6에서 Microsoft는 식본문함수member(expression-bodied function member)의 지원을 추가했으며 이전의 예제에서도 이를 활용한 바 있습니다. C#에서 lambda는 함수로부터 반환값을 표현하기 위해 => 문자를 사용합니다.
숫자의 Fibonacci 수열은 항상 0과 1로 시작합니다. 그러면 수열의 나머지는 이전 2개의 숫자를 같이 더하는 규칙을 통해 아래와 같이 생성됩니다.
0 1 1 2 3 5 8 13 21 34 55... |
따라서 수열의 다음 순서는 34 + 55이므로 89가 됩니다.
위와 같은 Fibonacci수열로 명령적 함수와 선언적 함수를 구현하는것의 차이를 설명할 수 있습니다.
Function.cs에서 명령적 함수 형태로 FibImperative이름의 함수를 아래와 같이 추가합니다.
static int FibImperative(int term)
{
if (term == 1)
{
return 0;
}
else if (term == 2)
{
return 1;
}
else
{
return FibImperative(term - 1) + FibImperative(term - 2);
}
}
이어서 RunFibImperative이름의 함수를 추가합니다. 이 함수는 1부터 30까지 순회하는 for문안에서 FibImperative 함수를 호출합니다.
static void RunFibImperative()
{
for (int i = 1; i <= 30; i++)
{
Console.WriteLine("The {0} term of the Fibonacci sequence is {1:N0}.", arg0: CardinalToOrdinal(i), arg1: FibImperative(term: i));
}
}
Progam.cs에서는 기존의 구문을 모두 제거하고 RunFibImperative만을 호출하도록 합니다. 그리고 project를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
다시 Function.cs로 돌아와 FibFunctional 함수를 추가합니다. 해당 함수는 선언적 형태로 작성되었습니다.
static int FibFunctional(int term) => term switch
{
1 => 0,
2 => 1,
_ => FibFunctional(term - 1) + FibFunctional(term - 2)
};
그리고 1부터 30까지 순회하는 for문 안에서 위의 함수를 호출하는 RunFibFunctional 함수를 아래와 같이 추가합니다.
static void RunFibFunctional()
{
for (int i = 1; i <= 30; i++)
{
Console.WriteLine("The {0} term of the Fibonacci sequence is {1:N0}.", arg0: CardinalToOrdinal(i), arg1: FibFunctional(term: i));
}
}
그리고 역시 Progam.cs안에서 RunFibImperative 함수를 호출하도록 하고 예제를 실행하면 이전과 동일한 결과가 됨을 알 수 있습니다.
2. Debugging
개발과정에서는 문제점을 해결하기 위해 debugging이라는 과정을 거쳐야 하며 실제 그렇게 하기 위한 방법에 대해 알아보고자 합니다. 이를 위해 개발자는 Visual Studio 2022나 Visual Studio Code와 같은 debugging 도구를 가진 code editor를 사용할 수 있습니다.
어떤 경우는 Visual Studio Code에서 OmniSharp debugger를 설정하는 것이 어렵다고 느껴질 수 있는데 이에 대한 정보는 아래 link를 참고할 수 있습니다.
omnisharp-vscode/debugger.md at master · OmniSharp/omnisharp-vscode · GitHub
(1) debugging에서 Visual Studio Code에 통합된 Terminal 사용하기
기본적으로 OmmiSharp은 debugging동안에 내부 console을 사용하도록 console을 설정하는데 여기서 한가지 문제점은 ReadLine method를 통한 text입력과 같은 상호작용을 지원하지 않는다는 것입니다. 따라서 console대신 통합 termianl을 대신 사용할 수 있도록 설정을 변경할 것입니다.
breakpoint를 걸고 단계적으로 code를 진행함으로서 debugging 하고자 하는 project의 folder로 들어간 뒤 .vscode folder로 들어가면 launch.json file을 볼 수 있습니다. 해당 file을 열어 console의 설정을 'internalConsole'에서 'integratedTerminal'로 변경합니다.
{
"version": "0.3.2",
"configurations": [
{
"console": "integratedTerminal",
]
}
(2) 고의적인 bug로 code 생성하기
예제로서 실제 code editor의 debugger도구를 사용해 bug를 찾고 수정해볼 텐데 이를 위해 해당 bug 가진 console app을 생성합니다.
csStudy04 solution에서 'Debugging'이라는 이름의 project를 생성한 뒤 Program.cs를 아래와 같이 수정합니다.
double Add(double a, double b)
{
return a * b;
}
그리고 해당 함수의 위에서 아래와 같이 몇개의 변수를 선언하고 값을 설정하는 구문과 함께 위 함수를 호출하는 구문을 추가합니다.
double a = 4.5;
double b = 2.5;
double answer = Add(a, b);
Console.WriteLine($"{a} + {b} = {answer}");
위 예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
결과를 보면 4.5와 2.5를 더한 7이 나와야 하지만 11.25가 나옴으로써 code안에 bug가 있음을 알 수 있습니다.
(3) 중단점설정및 debugging
중단점(Breakpoint)은 Program의 상태를 확인하고 bug를 찾기 위해 실행 중 멈추고자 하는 code의 line을 표시하는 것입니다.
Visual Studio 2022의 code editor에서 a이름의 변수를 선언한 구문이 있는 첫 행을 click 합니다. 그리고 Debug->Toggle Breakpoint menu 또는 F9 key를 누릅니다. 그러면 다음과 같이 붉은색 원이 좌변 여백공간에 표시되고 해당 구문이 Breakpoint가 설정되었음을 알리기 위해 붉은색으로 강조될 것입니다.
이렇게 설정된 Breakpoint는 같은 동작을 통해 설정이나 해제를 반복할 수 있습니다. 또한 여백자체를 mouse 왼쪽 click해도 마찬가지 동작을 수행합니다. 여백을 오른쪽 click 하면 삭제, 비활성 혹은 조건변경이나 현재 breakpoint에 대한 동작을 의미하는 더 많은 option을 표시합니다.
이제 Debug->Start Debugging이나 F5 key를 누르면 Visual Studio는 console application을 시작하게 되고 실행시점이 설정해둔 breakpoint에 도달하게 되면 일시적으로 실행을 멈추게 됩니다. 이 상태를 break mode라고 하며 Visual Studio안에서 Locals(지역변수의 현재 값을 표시하는 창), Watch 1(정의한 모든 watch 표현식을 표시), Call Stack, Exception Settings 그리고 Immediate창을 확장하여 표시합니다. 또한 다음으로 실행될 line은 노란색으로 표시되며 여백으로부터 노란색 화살표가 해당 line을 다음과 같이 표시합니다.
(4) Debugging toolbar
Visual Studio 2022는 Standard toolbar와 Debug toolbar라는 2개의 group으로 debug관련 button들을 가지고 있습니다. Standard toolbar는 debugging을 시작하거나 계속진행하고 동작중인 code의 변경사항을 hot reload하기 위한 것이며 그 외 tool의 나머지는 Debug toolbar로 분리되어 있습니다.
- Start/Continue/F5 : 이 button은 상황에 따라 다르게 동작합니다. project의 동작을 시작하거나 현재 위치에서 code의 종료시 또는 다른 breakpoint까지 실행을 계속 진행하도록 합니다.
- Hot Reload : 이 button은 compile된 code의 변경사항을 app을 재실행하지 않고도 다시 load 하도록 합니다.
- Break All : 이 button은 동작중인 app에서 다음 가능한 code의 line으로 일시중지합니다.
- Stop Debugging/Stop/Shift + F5 (붉은색 사각형) : 이 button은 debugging session을 중지합니다.
- Restart/Ctrl or Cmd + Shift + F5 (원형 화살표) : 이 button은 현재 debugging session을 중지한 뒤 곧장 program에 debgger를 연결시킨 뒤 다시 실행합니다.
- Show Next Statement : 이 button은 현재 cursor를 다음 실행될 구문으로 이동시킵니다.
- Step Info/F11, Step Over/F10, Step Out/Shift + F11 (반점이 있는 파란색 화살표) : 이들 button은 code구문을 다양한 방법을 통해 단계적으로 실행시킴으로써 실행 순간을 확인할 수 있도록 합니다.
- Show Threads in Source : 이 button은 debugging중인 application에 대한 thread를 검사하고 작업할 수 있도록 합니다.
(5) Debugging Windows
debugging하는 동안 Visual Studio 2022에서는 여러 window화면을 통해 code를 단계적으로 실행하면서 변숫값의 변화등을 관찰하는 등 여러 유용한 정보를 확인할 수 있습니다.
debugging시에 가장 많이 사용되는 windows창으로는 아래와 같은 것들이 있습니다.
- VARIABLES : Locals를 포함해 모든 지역변수에 대한 이름, 값, type등을 자동적으로 확인할 수 있습니다. 이를 통해 code를 단계적으로 실행하는 동안 그때그때 변화되는 정보를 관찰합니다.
- WATCH / Watch 1 : 변수의 값과 임의로 입력한 표현식을 나타냅니다.
- CALL STACK : 함수 호출간 stack정보를 표시합니다.
- BREAKPOINTS : 모든 breakpoint를 표시하고 이들을 세밀하게 제어할 수 있습니다.
break mode인 경우에는 edit의 아래에서 다음 window화면을 사용할 수 있습니다.
- DEBUG CONSOLE 또는 Immediate Window : code와의 상호작용을 통해 program의 상태를 질의할 수 있습니다. 예를 들어 변수의 이름을 입력해 해당 변수의 값을 확인하거나 1 + 2와 같은 식을 요청하여 그 결과를 확인할 수 있습니다.
(6) code의 단계적 실행
Visual Studio를 통해 code를 단계적으로 실행하려면 Debug -> Step Info menu를 선택하거나 toolbar에 있는 Step Into button 또는 F11키를 눌러줍니다. 그러면 노란색 강조 부분이 다음 line으로 이동하게 됩니다.
Debug -> Step Over menu를 선택하거나 toolbar에 있는 Step Over button 또는 F10키를 눌러도 노란색 강조부분이 다음 line으로 이동해서 이전과 동일하다고 생각될 수 있습니다. 그러나 현재 단계적으로 실행되고 있는 부분이 method라면 Step Into는 debugge가 method실행 내부로 들어가게 되므로 method의 모든 line을 단계적으로 실행하게 되지만 Step Over는 method전체를 한 번에 실행한 후 method다음의 line으로 이동한다는 차이가 있습니다.
Step Into를 눌러 method안으로 진입합니다. 이때 mouse pointer를 매개변수 a나 b위에 올려두면 tooltip을 통해 해당 변수의 현재값을 표시할 것입니다.
이번에는 a * b식을 선택한 뒤 mouse 오른쪽 button을 눌러 Add Watch를 선택합니다. 해당 식은 Watch 1창으로 추가되고 a와 b의 *연산에 대한 결과가 11.25 임을 표시하게 됩니다.
Watch 1 window에서 해당 식에 다시 mouse 오른쪽 button을 눌러 Delete Watch를 선택하여 추가된 a * b식을 삭제합니다.
이제 결과를 확인하여 문제가 무엇인지를 확인했으니 함수에서 *기호를 +로 변경합니다.
debugging을 멈추고 다시 재 compile을 수행한 다음 toolbar에서 Restart button이나 Ctrl + Shift + F5 key를 눌러 debugging을 재시작합니다.
이번에는 함수를 단계적으로 실행하고 계산되는 정확성을 확인한 후 Continue button을 누르거나 F5 key를 눌러줍니다.
(7) breakpoint 활용하기
breakpoint자체도 여러 유용한 설정을 적용할 수 있습니다.
우선 debugging중이라면 toolbar의 Stop button을 누르거나 Debug -> Stop Debugging menu 또는 Shift + F5 key를 눌러 debugging을 중지합니다.
Debug->Delete All Breakpoints menu를 선택해 현재 설정된 모든 breakpoint를 삭제합니다.
code상에 Console.WriteLine구문을 click 하고 F9 key 또는 Debug -> Toggle Breakpoint menu를 선택해 breakpoint를 설정합니다.
이어서 breakpoint를 mouse오른쪽 button으로 click한 후 Conditions menu를 선택합니다.
해당 설정화면에서 다음과 같이 answer변수가 9이상이 되어야 한다는 수식을 입력하고 'Close' button을 눌러줍니다. 이때 Is true는 입력한 수식으로 breakpoint가 작동하기 위해서는 true가 되어야 한다는 것을 의미합니다.
이렇게 설정한 후 다시 debugging을 시도하면 breakpoint가 작동하지 않음을 알 수 있습니다. condition으로 입력한 수식의 조건이 맞지 않기 때문입니다. 따라서 같은 방법으로 condition을 6으로 설정한 뒤 다시 debugging을 시도하면 이번에는 breakpoint가 작동하게 될 것입니다.
이번에는 조금 다르게 condition을 설정해볼텐데 condition설정화면에서 'Add condition'을 click 하여 새로운 condition을 입력할 수 있도록 합니다. 그리고 Hit count의 값을 다음과 같이 3으로 설정합니다.
해당 설정은 breakpoint가 작동하기 위해서 중단점 부분의 구문이 3번 평가되어야 함을 의미합니다.
설정이 완료된 후 breakpoint로 mouse를 올리게 되면 다음과 같이 설정상태가 tooltip으로 표시되는데 이를 통해 condition설정화면을 다시 열지 않고도 현재의 설정상태를 확인할 수 있습니다.
3. Hot reload
hot reload는 app을 debugging하는 중에도 code를 변경하고 그 즉시 변경된 사항을 반영하여 app의 실행을 확인해 볼 수 있도록 하는 기능입니다. app이 가진 bug를 빠르게 수정할 수 있게 하는 것으로 'Edit and Continue'이라고도 합니다. code를 변경할 수 있도록 hot reload가 지원하는 code의 목록은 아래 link에서 확인할 수 있습니다.
Supported Code Changes (C# and Visual Basic) - Visual Studio (Windows) | Microsoft Learn
.NET 6가 release 되기 전 Microsoft의 직원은 Hot reload를 Visual Studio 전용기능을 만들려고 시도했는데 Microsoft내 open-source관련 부서에서는 이와 같은 결정을 뒤집어 command-line에서도 사용가능하도록 하였습니다.
'csStudy04' solution에서 'HotReloading' project를 추가하고 Program.cs에서 기존 구문을 모두 제거한 뒤 2초마다 console로 message를 출력하는 아래 예제를 작성합니다.
while (true)
{
Console.WriteLine("Hello, Hot Reload!");
await Task.Delay(2000);
}
(1) Visual Studio 2022에서 Hot reload사용하기
project를 실행하고 매2초마다 console에 출력되는 message를 확인합니다. 이 상태에서 Debug->Apply Code Changes menu를 선택하거나 toolbar의 Hot Reload button을 click 하여 message를 'Hello to Goodbye'로 변경합니다. 그러면 message는 console app을 재시작하지 않고도 변경사항이 적용될 것입니다.
'Hot Reload' button의 mene를 내려 'Hot Reload on File Save'를 check하면 source code의 file이 저장될 때마다 변경사항이 자동작으로 반영될 것입니다.
(2) Visual Studio Code와 commnad line에서 Hot reload사용하기
Visual Studio Code에서는 console app을 실행할때 Hot Reload를 사용하기 위해서 특별한 명령을 내려야 합니다. Visual Studio Code의 TERMINAL이나 Windows Terminal을 열고 해당 project가 존재하는 folder로 이동한 뒤 'dotnet watch'명령을 내립니다. 그러면 다음과 같이 console app이 hot reload가 활성화되어 app이 실행될 것입니다.
이 상태에서 이전과 마찬가지로 message를 'Hello to Goodbye'로 변경하여 2초후에 해당 변경내용이 적용되는지 확인합니다.
app의 실행을 중단하고자 한다면 'Ctrl + C' key를 누릅니다.
4. 개발중에서와 runtime에서의 Logging
code에서 모든 bug가 제거되었다고 판단된다면 release version compile 하고 사람들이 사용할 수 있도록 application을 배포할 수 있을 것입니다. 하지만 그럼에도 불구하고 code상에는 잠재적인 bug가 존재할 수 있고 runtime동안에 예상하지 못한 에러가 유발될 수 있습니다.
사용자는 오류가 발생했을때를 기억하고 그때 자신이 무엇을 했는지에 대해 자세하게 설명하지 못하는 경우가 대부분입니다. 따라서 문제점을 파악하고 수정할 수 있도록 오류상황을 재현하기 위해 필요한 정보를 제공할 때 이러한 정보에 의존해서는 안됩니다. 대신 관련된 event를 기록함으로써 code에 대한 문제점을 측정할 수 있습니다.
무슨 일이 일어나고 있는지에 대한 log를 위해, 특히 예외가 발생했을 때를 위해 application전역에 관련 code를 추가합니다. 그리고 이러한 log를 검토하여 문제점을 추적하고 이를 해결하기 위해 사용해야 합니다.
(1) logging option
.NET은 logging기능을 추가함으로써 code를 측정하기 위한 몇몇 기본적인 방법을 포함하고 있습니다. 여기서 우리는 기본적인 부분만을 살펴볼 것입니다. 사실 logging은 제삼자가 Microsoft가 제공하는 것을 확장한 강력한 solution의 풍부한 생태계로 만들어져 온 영역입니다. 따라서 어떤 것이 필요한지는 필요한 상황에 따라 달라질 수 있으므로 특정한 것을 사용하라고 권장할 수는 없습니다. 그러나 대개 아래 항목의 logging을 많이 사용하는 것으로 보입니다.
- Apache log4net
- NLog
- Serilog
(2) Debug와 Trace를 사용한 추적
우선 code에 간단한 logging을 추가하기 위한 2가 유형이 존재하는데 그것이 바로 Debug와 Trace입니다.
- Debug : 개발하는 동안에 작성되는 logging을 위해 사용되는 class입니다.
- Trace : 개발과 runtime둘다 작성되는 logging을 위해 사용되는 class입니다.
지금까지의 예제를 통해 우리는 Console app에서 무엇인가를 출력하기 위해 WriteLine method를 사용했었습니다. Debug와 Trace class도 비슷하게 무엇인가를 출력하기 위해 사용되는데 이 출력의 대상이 trace listener가 되며 trace listener는 WriteLine method를 호출할 때 원하는 곳 어디든 출력을 쓰도록 구성할 수 있는 type입니다. .NET에서는 console로의 출력을 포함해 자체적으로 제공하는 몇몇 trace listener가 존재하고 있고 필요하다면 TraceListener type에서 상속하여 자신만의 listener를 직접 만들 수도 있습니다.
● 기본 trace listener로 작성하기
하나의 trace listener인 DefaultTraceListener class가 자동적으로 구성되고 Visual Studio의 Debug window에 기록되면 code를 사용해 다른 trace listener를 구성할 수 있습니다.
csStudy04 solution에서 Instrumenting이름의 project를 추가하고 Program.cs에서 기존구문을 모두 제거한 뒤 System.Diagnostics namespace를 import 하고 뒤이어 Debug와 Trace class에서 message를 기록하도록 합니다.
using System.Diagnostics;
Debug.WriteLine("Debug says, I am watching!");
Trace.WriteLine("Trace says, I am watching!");
Visual Studio에서 View -> Output menu를 선택하고 'Show output from'에서 'Debug'를 선택한 다음 Instrumenting console app의 debugging을 시작합니다. Visual Studio 2022 Output window에서 assembly DLL의 loaded 다른 debugging정보와 함께 2개의 message가 표시됨을 볼 수 있을 것입니다.
Debug says, I am watching! Trace says, I am watching! |
(3) trace listener 구성
이번에는 text file에 기록하는 trace listener를 설정해 볼 것입니다.
Debug와 Trace에서 WriteLine을 호출하기 전에 바탕화면으로 새로운 text file을 생성하고 해당 file로 log를 기록하는 trace listener를 전달하는 구문과 함께 buffer로 자동 flush 되도록 설정하는 code를 추가합니다.
using System.Diagnostics;
string logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "log.txt");
Console.WriteLine($"Writing to: {logPath}");
TextWriterTraceListener logFile = new(File.CreateText(logPath));
Trace.Listeners.Add(logFile);
Trace.AutoFlush = true;
file을 나타내는 모든 type은 대게 성능향상을 위해 buffer를 구현합니다. file로 즉시 기록하는 대신 작성된 data를 memory buffer에 담아두고 buffer가 일정용량을 차지하게 되면 그때까지의 data전체를 file로 기록하는 것입니다. 이러한 동작으로 인해 예제를 실행할 때는 즉각적인 결과를 학인할 수 없을 수도 있습니다. 예제의 AutoFlush는 Flush method가 매번 기록 후에 호출되도록 합니다.
Visual Studio에서는 toolbar에 있는 Solution Configrurations의 drop-down목록에서 Release를 선택하고 Debug->Start Without Debugging menu를 선택해 예제를 실행합니다. 그런 뒤 바탕화면에서 log.txt file을 열어 'Trace says, I am watching!'문구가 기록되어 있는지 확인합니다.
Debug설정으로 app을 동작시킬 때는 Debug와 Trace가 활성화되어 trace listener로 기록을 수행할 것입니다. 반면 Release설정으로 app이 동작될 때는 오로지 Trace만 trace listener로 기록을 시도합니다. 따라서 Debug.WriteLine의 호출은 application을 release version으로 build 하게 되면 자동으로 제거되고 따라서 성능에 영향을 주지 않을 것이므로 code전역에서 자유롭게 사용할 수 있습니다.
(4) trace 수준 전환하기
Trace.WriteLine를 호출하는 것은 release이후에도 code에 남아있으며 이들에 대한 output은 trace switch르 통해 어느 정도 제어할 수 있습니다.
trace switch의 값은 숫자나 단어로 설정할 수 있습니다. 예를 들어 숫자 3은 아래 표에서와 같이 Info라는 단어로 바뀔 수 있습니다.
Number | Word | Description |
0 | Off | 아무것도 출력하지 않습니다. |
1 | Error | Error만 출력합니다. |
2 | Warning | Error와 Warning만 출력합니다. |
3 | Info | Error와 Warning, Infomation만 출력합니다. |
4 | verbose | 전체 수준을 모두 출력합니다. |
trace switch를 사용하기 위해서는 우선 필요한 NuGet package를 설치하여 JSON appsettings file로부터 구성설정값을 load 할 수 있도록 해야 합니다.
● Project에 package 추가하기
Visual Studio의 project에서 mouse 오른쪽 button을 눌러 'Manage NuGet Packages'를 선택합니다. 그리고 Browse tab을 선택한 뒤 'Microsoft.Extensions.Configuration'을 입력하고 아래 항목의 NuGet package를 검색해 설치합니다.
- Microsoft.Extensions.Configuration.Binder
- Microsoft.Extensions.Configuration.Json
이외에도 XML file이나 INI, 환경 변수, command line에서 설정을 loading 하는 package도 존재합니다. 따라서 project의 환경설정에 가장 적합한 것을 사용하면 됩니다.
● Project package 확인
NuGet package가 설치되고 나면 project file의 <ItemGroup> section에서 다음과 같이 참조를 확인할 수 있습니다.
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
</ItemGroup>
이제 project에 'appsettings.json'이름의 file을 추가하고 LogSwitch이름의 설정을 Level값과 함께 정의합니다.
{
"LogSwitch": {
"Value": "Info"
}
}
Microsoft는 .NET 7의 최종 release이후 7.0.3 version의 Microsoft.Extensions.Configuration.Binder package에 존재해 있던 bug를 수정하였습니다. 이 bug는 이전 version에서 설정을 읽는 방식때문에 예외가 발생할 수 있습니다. Microsoft.Extensions.Configuration.Binder 7.0.3이전까지는 Level속성을 "Level": "Info"처럼 설정하는게 가능했지만 bug가 수정된 이후에는 내부 class에서 Value속성이 설정되어야 하기 때문에 예외를 발생시킬 수 있으므로 Value속성을 설정하거나 아니면 둘의 속성값 모두를 설정해야 합니다.
https://github.com/dotnet/runtime/issues/82998
추가된 appsettings.json file에서 mouse오른쪽 button을 눌러 'Properties'를 선택한 다음 'Copy to Output Directory'설정을 'Copy if newer'로 변경합니다. 이 설정은 project folder에서 project의 console app을 동작시키는 Visual Studio Code와는 달리 Instrumenting\bin\Debug\net7.0 또는 Instrumenting\ bin\Release\net7.0에서 project app을 동작시키게 되므로 필요한 설정입니다.
Program.cs에서는 다음과 같이 Microsoft.Extensions.Configuration를 import 하고
using Microsoft.Extensions.Configuration;
file의 끝에서는 아래와 같은 구문을 추가합니다. 해당 문은 현재 folder에서 appsettings.json file을 확인하기 위한 configuration builder를 생성하고 설정을 build 하며 trace switch를 생성한 후 여기에 설정을 binding 함으로써 level을 설정하고 4개의 switch level을 출력하도록 합니다.
Console.WriteLine("Reading from appsettings.json in {0}", arg0: Directory.GetCurrentDirectory());
ConfigurationBuilder builder = new();
builder.SetBasePath(Directory.GetCurrentDirectory());
builder.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
IConfigurationRoot configuration = builder.Build();
TraceSwitch ts = new(displayName: "LogSwitch", description: "This switch is set via a JSON config.");
configuration.GetSection("LogSwitch").Bind(ts);
Trace.WriteLineIf(ts.TraceError, "Trace error");
Trace.WriteLineIf(ts.TraceWarning, "Trace warning");
Trace.WriteLineIf(ts.TraceInfo, "Trace information");
Trace.WriteLineIf(ts.TraceVerbose, "Trace verbose");
Console.ReadLine();
Bind구문에 breakpoint를 설정하고 debugging을 시작합니다. debugging이 시작된 뒤 Locals window에서 ts변수식을 확장해 보면 다음과 같이 Level과 TraceError, TraceWarning 등이 모두 false로 되어 있음을 확인할 수 있습니다.
이 상태에서 Step Into 또는 Step Over button을 누르거나 F11 또는 F10 key를 눌러 Bind method를 호출하여 ts 변수식에서 level이 Info로 변경되는 것을 확인합니다.
계속해서 4개의 Trace.WriteLineIf의 호출 부분을 실행함으로써 Info로 변경된 모든 level이 Verbose를 제외하고 Output - Debug window에 표시됨을 확인합니다.
Debugging을 중지하고 이번에는 appsettings.json에서 'Value'의 값을 2(warning)로 변경한 뒤 'Solution Configurations'의 설정을 Release로 선택하고 Debug -> Start Without Debugging으로 console app을 실행합니다.
그리고 바탕화면의 log.txt file을 열어보면 4개의 trace level 중에서 trace error와 warning level만이 출력되어 있음을 확인할 수 있습니다.
Trace says, I am watching! Trace error Trace warning |
만약 어떠한 인수도 전달하지 않았다면 기본 trace switch level은 0(OFF)이 되고, 그러면 어떠한 switch level로 출력되지 않을 것입니다.
(5) source code에 관한 Logging 정보
log를 사용할 때 때로는 source code file의 이름이나 method이름, 혹은 line number와 같은 source code의 정보를 포함하고 싶을 수 있습니다. 심지어 C# 10부터는 함수의 인수로 전달된 모든 수식을 문자열값으로 가져올 수 있으므로 이를 log화 하는 것도 가능하게 되었습니다.
이러한 모든 정보는 함수의 매개변수에 아래 table에 나타난 특별한 attribute를 적용함으로써 가져올 수 있습니다.
[CallerMemberName] string member = "" | member라는 이름의 string 매개변수에 매개변수가 정의된 해당 method를 실행하는 method나 property이름을 설정합니다. |
[CallerFilePath] string filepath = "" | filepath라는 이름의 string 매개변수에 매개변수가 정의된 해당 method를 실행하는 구문을 포함한 source code file의 이름을 설정합니다. |
[CallerLineNumber] int line = 0 | line이라는 int 매개변수에 매개변수가 정의된 해당 method를 실행하는 구문을 가진 source code file의 line번호를 설정합니다. |
[CallerArgumentExpression( nameof(argumentExpression))] string expression = "" | expression이라는 string 매개변수에 argumentExpression이름의 매개변수로 전달된 식을 설정합니다. |
해당 매개변수는 기본값을 할당하여 선택적 매개변수로 만들어야 합니다. 그럼 예제를 통해 실제 어떻게 해당 attribute를 사용할 수 있는지 알아보겠습니다.
Instrumenting project에서 Methods.cs이름의 class file을 추가하고 해당 file에 LogSourceDetails이름의 함수를 정의합니다. 이 함수는 호출 code에 대한 정보를 log 하기 위해 위에서 설명한 4개의 attribute를 사용합니다.
partial class Program
{
static void LogSourceDetails(bool condition, [CallerMemberName] string member = "", [CallerFilePath] string filepath = "", [CallerLineNumber] int line = 0, [CallerArgumentExpression(nameof(condition))] string expression = "")
{
Trace.WriteLine(string.Format("[{0}]\n {1} on line {2}. Expression: {3}", filepath, member, line, expression));
}
}
Program.cs에서는 file의 마지막 Console.ReadLine()를 호출하기 전에 LogSourceDetails함수로 전달될 식에서 사용되는 변수를 선언하고 값을 할당하는 구문을 추가합니다.
Trace.WriteLineIf(ts.TraceVerbose, "Trace verbose");
int unitsInStock = 12;
LogSourceDetails(unitsInStock > 10);
Console.ReadLine();
예제에서는 고정된 값의 식을 사용하지만 실제 project에서는 사용자에 의해 동적으로 생성되는 식이 될 수 있습니다.
debugging 없이 예제를 실행한 뒤 console app을 닫고 바탕화면의 log.txt를 보면 다음과 같이 file이 기록되어 있음을 확인할 수 있습니다.
[C:\Users\clicl\source\repos\csStudy04\Instrumenting\Program.cs] <Main>$ on line 32. Expression: unitsInStock > 10 |
5. 단위 TEST
완성된 application에서 오류를 수정하기보다는 가급적 개발과정에서 오류를 발견하는 것이 훨씬 좋다는 건 두말할 나위가 없을 것입니다.
이를 위해 단위 test라는 것이 존재하며 개발 중에서 bug를 찾기 위한 좋은 방법입니다. 어떤 개발자는 실제 code를 작성하기 전에 단위 test를 먼저 생성하는 원칙을 따르고 있으며 이러한 개발 방법을 TDD(Test-Driven Development)라고 합니다.
Microsoft는 단위 test를 위한 framework로 MSTest를 제공하고 있으며 NUnit라는 또 다른 framework도 존재합니다. 여러 가지 단위 test framework를 사용할 수 있지만 예제에서는 xUnit.net을 사용할 것입니다. 이 framework는 무료이며 open-source입니다.
(1) Testing type
단위 test는 test를 위한 여러 유형 중의 하나이며 아래 표와 같은 다른 test 유형도 존재합니다.
Unit | code의 작은 단위에 대한 test로서 method나 function과 같은 것입니다. 단위 test는 필요한 경우 이들을 mocking함으로서 의존성으로 부터 격리된 code의 단위를 수행합니다. 일반적인 입력과 예상되는 출력, 한계 test를 위한 극한입력, 잘못된 입력을 통한 예외처리 test등 여러 test를 가질 수 있습니다. |
Integration | 작은 단위와 큰 component가 하나의 software로서 작동할 수 있는지를 test하는 것으로 source code가 없는 외부 component와의 통합을 포함합니다. |
System | Software가 작동할 전체 system 환경을 test합니다. |
Performance | Software의 성능을 test하는 것으로 수초이내에 data가 채워진 web page를 반환해야 하는 경우등을 들 수 있습니다. |
Load | 얼마나 많은 요청을 software가 필요한 성능을 유지하면서 동시적으로 처리할 수 있는가에 대한 test로서 10000명의 사용자가 동시에 방문하는 경우등을 들 수 있습니다. |
User Acceptance | 사용자가 software를 사용하여 만족스럽게 필요한 작업을 수행할 수 있는지의 여부를 test합니다. |
(2) test를 수행하는 class library 생성
가장 먼저 test를 수행하는 함수를 만들어야 합니다. 이를 위해 기존 예제의 console app project와는 별개의 class library project를 생성해야 하며 해당 class library는 다른 .NET application에서 참조되고 배포될 수 있는 code의 package입니다.
csStudy04 solution에서 'CalculatorLib'이름의 class library project를 생성하고 'Class1.cs'이름의 file을 ' Calculator.cs'로 변경한 후 해당 file을 아래와 같이 작성합니다. (고의적으로 잘못된 code를 사용하는 것이므로 있는 그대로 작성합니다.)
namespace CalculatorLib
{
public class Calculator
{
public double Add(double a, double b)
{
return a * b;
}
}
}
그리고 해당 project를 build 합니다.
다음으로 csStudy04 soluction에 'CalculatorLibUnitTests'이름의 xUnit Test Project를 추가한 뒤 해당 project에서 위에서 만든 CalculatorLib를 참조추가하고
해당 project를 build 합니다.
(3) 단위 test 작성
단위 test는 세 부분으로 구성됩니다.
- Arrange : 입력과 출력을 위한 변수를 선언하고 instance 하는 부분입니다.
- Act : test 할 단위를 실행하는 부분입니다. 지금의 경우에는 test할 method를 호출하는 것이 됩니다.
- Assert : 출력에 관한 하나 또는 그 이상의 assertion을 만드는 부분으로 assertion은 결과가 true가 아니면 test는 실패했음을 표시합니다.
이제 Calculator class에 대한 몇 가지 단위 test를 만들어볼 것입니다.
우선 'UnitTest1.cs'file을 'CalculatorUnitTests.cs'로 변경하고 file안에서 CalculatorLib namespace를 import 합니다. 그리고 CalculatorUnitTests class에 2개의 method를 추가할 텐데 하나는 2와 2를 더하는 것이고 다른 하나는 2와 3을 더하는 method입니다.
using CalculatorLib;
namespace CalculatorLibUnitTests
{
public class CalculatorUnitTests
{
[Fact]
public void TestAdding2And2()
{
// arrange
double a = 2;
double b = 2;
double expected = 4;
Calculator calc = new();
// act
double actual = calc.Add(a, b);
// assert
Assert.Equal(expected, actual);
}
[Fact]
public void TestAdding2And3()
{
// arrange
double a = 2;
double b = 3;
double expected = 5;
Calculator calc = new();
// act
double actual = calc.Add(a, b);
// assert
Assert.Equal(expected, actual);
}
}
}
● 단위 test 실행하기
위에까지 진행하면 이제 단위 test를 실행할 준비가 완료된 것입니다. Visual Studio에서 Test -> Run All Tests menu를 선택합니다. 그러면 Test Explorer에서 2개의 test동작에 대한 결과를 표시할 텐데 아마도 하나는 성공(passed), 다른 하나는 실패(Failed)로 표시할 것입니다.
● bug 수정하기
Add method에서 a * b부분을 a + b로 변경하고 다시 test를 실행합니다. 그러면 이번에는 모든 method가 test에 통과했음을 표시할 것입니다.
6. 함수에서의 예외 throwing과 catching
예외 처리에 관해서는 아래 글에서 try-catch문을 사용해 본 바 있습니다.
[.NET/C#] - [C# 12와 .NET 8] 3. 흐름제어, Type 변환, 예외 처리
그러나 문제를 완화하기 위한 충분한 정보를 가지고 있는 경우에만 예외를 catch 하고 처리해야 합니다. 그럴 수 없는 경우에 예외는 상위 수준으로 호출 stack을 통해 전달되도록 해야 합니다.
(1) usage error와 execution error
Usage error는 개발자가 함수를 잘못 사용하는 경우와 같은 것이며 일반적으로 매개변수로 잘못된 값을 전달하는 경우를 들 수 있습니다. 이런 경우에는 개발자가 정확한 값을 전달하도록 code를 변경함으로써 처리할 수 있습니다. programming을 처음 접하는 경우에는 모든 error가 usage error라고 추정함으로써 예외를 피할 수 있을 것이라 생각하기도 하지만 실제로 그렇게 하는 것은 불가능하며 Usage error는 application이 제품화되어 실행되기 전에 모두 수정되어야 합니다.
Execution error는 application이 code를 보완하여 수정할 수 없는 실행 시에 발생하는 것입니다. Execution error는 세부적으로 program error와 system error로 분리될 수 있습니다. 만약 application에서 network resource에 접근하려 할 때 network가 down 된 상태라면 예외를 기록하고 잠시 기다렸다 다시 시도함으로써 예외를 처리할 수 있을 것입니다. 하지만 memory부족과 같은 system error의 경우에는 처리가 쉽지 않을 수 있습니다. 존재하지 않는 file을 열려고 하는 경우에는 이에 대한 error를 catch 하고 program상에서 신규 file을 생성함으로써 처리할 수 있습니다. Program error는 code를 개선함으로써 수정할 수 있지만 System error는 종종 code의 개선만으로 해결할 수 없는 경우가 많습니다.
(2) 함수에서의 일반적인 예외
흔한 경우는 아니지만 usage error를 나타내기 위해 새로운 예외 type을 정의하는 경우도 있으며 .NET은 이미 사용가능한 많은 type을 정의하고 있습니다.
매개변수와 함께 함수를 정의할 때 code에서는 매개변수의 값을 확인하고 적절하게 작동할 수 없도록 하는 값을 가진 경우 예외를 발생시킬 수 있습니다.
예를 들어 함수의 인수가 null이라면 ArgumentNullException를 발생시킬 수 있을 것입니다. 그 외 다른 문제라면 ArgumentException, NotSupportedException 또는 InvalidOperationException을 발생시킬 수도 있고 어떠한 문제든 아래와 같은 code를 통해 문제점을 설명하는 message로 사람들(일반적으로 class library나 함수에 대한 개발자 또는 GUI app에서 최상위 수준인 경우 최종 사용자)에게 상세내용을 제공해 줄 수도 있습니다.
static void Withdraw(string accountName, decimal amount)
{
if (accountName is null)
{
throw new ArgumentNullException(paramName: nameof(accountName));
}
if (amount < 0)
{
throw new ArgumentException(message: $"{nameof(amount)} cannot be less than zero.");
}
}
새로운 exception가 발생할 때 if문을 사용하는 대신 .NET 6부터는 인수가 null인 경우 예외를 발생시키는 편리한 method를 도입하였고 아래와 같이 사용할 수 있습니다.
static void Withdraw(string accountName, decimal amount)
{
ArgumentNullException.ThrowIfNull(accountName);
}
C# 11 preview에서는 null 확인 연산자인!! 을 도입하여 위에서와 같은 기능을 수행하도록 하였으나 정식 version에서 제거되었습니다.
static void Withdraw(string accountName!!, decimal amount)
이러한 usage error를 잡기 위해서는 꼭 try-catch문을 사용할 필요는 없습니다. 이러한 예외로 인해 함수를 호출하는 개발자는 문제를 방지하기 위해 application이 배포되기 전 code를 수정해야 합니다. 이는 code가 usage error type의 예외를 발생시킬 필요가 없다는 것을 의미하는 것이 아니지만 다른 개발자들이 함수를 정확히 호출할 수 있도록 해야 합니다.
(3) call stack
.NET console application의 주요 진입점은 Program class의 Main(명시적으로 method를 정의한 경우, top-level program기능으로 만들어진 것이라면 <Main>$입니다.)이라는 이름의 method입니다.
Main method는 다른 method를 호출할 것이고 해당 method는 또 다른 method를 호출할 수 있을 것입니다. 그리고 이들 method는 현재 project나 참조된 project 또는 NuGet package에 존재할 수 있습니다.
이제 위 그림과 비슷한 method chain을 만들어 봄으로서 예로서 제시된 예외를 catch 하고 이를 적절한 처리하는 방법에 관해 알아볼 것입니다. csStudy04 solution에서 'CallStackExceptionHandlingLib'이름의 Class Library project를 추가하고 Class1.cs file을 Calculator.cs로 변경한 뒤 Calculator.cs를 아래와 같이 변경합니다.
namespace CallStackExceptionHandlingLib
{
public class Calculator
{
public static void Gamma() // public so it can be called from outside
{
Console.WriteLine("In Gamma");
Delta();
}
private static void Delta()
// private so it can only be called internally
{
Console.WriteLine("In Delta");
File.OpenText("bad file path");
}
}
}
이번에는 같은 solution에 Console App형식의 'CallStackExceptionHandling' project를 추가하고 해당 project에서 CallStackExceptionHandlingLib를 참조 추가합니다. 그리고 Program.cs에서 기존 구문을 삭제하고 2개의 method를 정의한 뒤 아래와 같이 class library의 method와 이들 method를 연쇄적으로 호출하도록 하는 구문을 추가합니다.
using CallStackExceptionHandlingLib;
using static System.Console;
WriteLine("In Main");
Alpha();
void Alpha()
{
WriteLine("In Alpha");
Beta();
}
void Beta()
{
WriteLine("In Beta");
Calculator.Gamma();
}
이제 debugging 없이 project를 실행하면 다음과 같은 결과를 표시할 것입니다.
call stack은 밑에서부터 거슬러 올라가야 하는데 이를 통해 우리는 다음과 같은 사항을 확인해 볼 수 있습니다.
- 처음 호출되는 것은 Program class에서 자동으로 생성된 <Main>$ entry point입니다. 여기에서 string배열을 통해 인수가 전달됩니다.
- 두 번째로 호출되는 것은 '<<Main>$>g_Alpha|0_0'함수입니다.(C# compiler는 Alpha를 지역함수로 추가할 때 이름을 바꾸게 됩니다.)
- 세 번째로 호출되는 것은 Beta 함수입니다.
- 네 번째로 호출되는 것은 Gamma 함수입니다.
- 다섯 번째로 호출되는 것은 Delta 함수입니다. 이 함수에서는 옳지 않은 경로를 전달하여 file열기를 시도하는데 이때 예외를 발생시키게 됩니다. 이때 try-catch를 가진 모든 함수는 이 예외를 잡아낼 것입니다. 그렇지 않다면 위 결과와 같이 call stack의 상세와 함께 예외를 표시하게 되는 최상위에 도달할 때까지 자동적으로 call stack의 위로 예외를 전달하게 됩니다.
debugging 하기 위한 code의 단계적 실행이 필요하지 않다면 code를 debugger 없이 실행할 수 있습니다. 이 경우 debugger를 연결하지 않는 것이 특히 중요합니다. 그러면 예외가 catch 되고 이를 위 결과와 같이 표시하는 대신 GUI dialog box를 통해 표시할 것입니다.
(4) 예외를 catch 할 수 있는 곳
개발자 입장에서는 처리가 실패한 곳에서 예외를 catch 할지, call stack의 상위에서 집중적으로 예외를 catch 할지를 결정할 수 있습니다. 이는 통해 code를 단순화시키고 표준화시키는데 도움이 됩니다. 예외를 호출하면 하나이상의 예외유형이 발생할 수 있지만 call stack의 현재 지점에서 이들을 처리할 필요는 없습니다.
(5) 예외 재발생
때로는 예외를 catch 하여 이를 기록하고 해당 예외를 다시 발생시키고 싶은 경우도 있습니다. 예를 들어 application에서 호출될 저수준 class library를 작성하는 경우 해당 code에서는 error를 수정하기에 충분한 정보를 가지지 못할 수 있지만 이를 호출하는 application에서는 더 많은 정보를 가지고 있을 수 있습니다. 따라서 code에서는 error가 발생한 경우의 log를 기록하고 call stack의 상위로 예외를 던져 호출 application에서 이를 처리할지를 결정하도록 할 수 있습니다.
이를 위해 catch block안에서는 예외를 던지는 3가지 방법이 있습니다.
- 본래 call stack에서 catch 된 예외를 다시 발생시키기 위해 throw를 호출합니다.
- call stack의 현재 수준에서 예외가 발생한 것처럼 catch 된 예외를 발생시키려면 catch된 예외와함께 'throw ex'처럼 throw를 호출합니다. 그러나 이는 debugging을 위해 필요한 정보를 잠재적으로 잃어버릴 수 있기 때문에 좋지 않은 방법이지만 예외(Exception)에서 민감한 정보를 포함하고 있는 경우 이를 고의적으로 삭제하기 위해서는 유용할 수 있습니다.
- 호출자가 문제를 이해하는데 도움이 될 수 있는 message같이 더 많은 정보를 포함할 수 있는 다른 예외로 catch 된 예외를 wrap합니다. 이를 새로운 예외를 발생시키고 catch된 예외를 innerException 매개변수로서 전달합니다.
예제에서 Gamma function을 호출할 때 예외가 발생했다면 해당 예외를 catch 하고 예외를 다시 발생시키기 위한 위의 3가지 방법 중 하나를 아래와 같이 수행할 수 있습니다.
void Beta()
{
WriteLine("In Beta");
try {
Calculator.Gamma();
}
catch(Exception ex)
{
WriteLine(ex.Message.ToString());
throw ex;
}
}
위 code를 실제 작성하면 throw ex밑에 녹색 밑줄이 표시되면서 call stack 정보를 손실할 수 있다는 경고를 표시할 수 있습니다.
예제를 실행하면 call stack에 대한 일부 상세정보를 제외한 결과를 다음과 같이 표시할 것입니다.
위 예제에서 throw ex를 throw로만 변경하고 다시 project를 실행하면 이번에는 call stack의 모든 상세를 포함한 결과를 표시합니다.
(6) tester-doer pattern 구현하기
tester-doer pattern은 일부 예외의 발생을 방지할 수 있습니다.(완전히 없애는 것이 아닙니다.) 이 pattern은 2개의 함수를 함께 사용하는 것으로 하나는 test를 수행하고 다른 하나는 test를 통과하지 못했다면 실패할 동작을 수행합니다.
.NET은 이러한 pattern을 자체적으로 구현하고 있습니다. 예를 들어 Add method를 호출하여 collection에 item을 추가하기 전에 이것이 읽기 전용인지를 확인하는 test를 수행함으로써 Add method가 실패되고 예외를 발생시킬 수 있습니다.
또 다른 예로 은행에서 돈을 출금하기 전 초과인출여부를 확인하는 test를 아래와 같이 수행하는 것입니다.
if (!bankAccount.IsOverdrawn())
{
bankAccount.Withdraw(amount);
}
● tester-doer patten의 문제점
tester-doer pattern은 성능 overhead를 일으킬 수 있습니다. 따라서 test와 작동함수를 하나의 단일 함수로 결합한 try pattern을 구현할 수도 있는데 TryParse가 바로 이러한 방식으로 구현된 것입니다.
tester-doer pattern의 또 다른 문제점은 다중 thread를 사용할 때 발생합니다. 이러한 경우 하나의 thread에서는 test함수를 호출하고 문제가 없음을 반환할 수 있습니다. 하지만 그때 다른 thread가 상태변경을 시도하는 것입니다. 그러면 원래 thread는 모든 것이 문제가 없다고 판단하고 자신의 실행을 계속 수행하지만 사실은 그렇지 않은 것입니다. 이러한 경우를 race condition이라고 합니다.
만약 자체 try pattern 함수를 구현했다면 해당 함수의 실행이 실패하는 경우를 대비해 해당 type의 기본값으로 out 매개변수를 설정하고 false를 반환하는 것이 좋습니다.
static bool TryParse(string? input, out Person value)
{
if (Failure)
{
value = default(Person);
return false;
}
// 성공한 경우
value = new Person() { ... };
return true;
}