https://www.codeproject.com/Articles/5345372/Web-Request-Sequence-Visualization
여기서는 나는 웹서비스를 요청하는 중간에 이벤트의 시퀀스 다이어그램을 신속하게 구성할 수 있는 시스템을 만들려고 합니다.
최신 웹 서비스에 대한 요청들은 매우 복잡합니다. 당신이 호출하는 서비스는 다른 서비스를 호출할 수 있습니다. 그것들은 등등의 다른 서비스들이 될 수 있습니다. 이러한 모든 요청들은 병렬로 실행될 수도 있습니다. 물론 로깅 시스템은 이러한 요청들에 참여한 모든 참가자의 정보들을 저장합니다. 그러나 다양한 서비스들의 시간는 어떤 면에서 동기화되지 않을 수 있으므로 이에 적합한 그림을 다시 생성하기가 쉽지 않습니다. 그리고 여기에 메시지 대기열(Azure EventHub, RabbitMQ, ...)들이 추가되면 작업이 훨씬 더 어려워집니다. 여기서 나는 이러한 웹 서비스들을 요청하는 중간에 이벤트의 시퀀스 다이어그램을 신속하게 구성할 수 있는 시스템을 만들어 볼려고 합니다.
분석을 위한 시스템
그럼 요청들을 분석하려는 시스템을 구축해 봅시다. 전체 코드를 GitHub에서 당겨 올 수 있습니다.
나의 시스템의 모습은 여러 서비스(Service1, Service2, Service3, ExternalService)들이 포함 할 것입니다:
ServiceN 서비스는 내 시스템의 구성 요소들입니다. 이 시스템들은 나의 통제하에 있습니다. 그들은 어떤 일을 하고, 로그에 무언가를 쓰고, 다른 서비스에 요청합니다. 여기서 해당 서비스들이 실제로 무엇을 하는지는 중요하지 않습니다. 아래는 이러한 서비스의 일반적인 예입니다:
[HttpGet]
public async Task<IEnumerable<WeatherForecast>> Get()
{
_logger.LogInformation("Get weather forecast");
Enumerable.Range(1, 4).ToList()
.ForEach(_ => _logger.LogInformation("Some random message"));
await Task.WhenAll(Enumerable.Range(1, 3).Select(_ => _service2Client.Get()));
await _service3Client.Get();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
이제 시스템이 준비되었으니, 구성해 봅시다.
시스템 설정
무엇보다, 나는 모든 로그를 한 곳으로 모으고 싶습니다. Docker와 함께 사용하기 쉽기 때문에 Seq를 사용할 것입니다. 다음은 해당 Docker Compose 파일입니다:
version: "3"
services:
seq:
image: datalust/seq
container_name: seq
environment:
- ACCEPT_EULA=Y
ports:
- "5341:5341"
- "9090:80"
이제 http://localhost:9090 주소를 통해서 Seq UI에 접근 할 수 있으며 Serilog 를 사용해서 Seq에 로그를 작성 할 수 있습니다:
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Error)
.MinimumLevel.Override("System", LogEventLevel.Error)
.Enrich.FromLogContext()
.WriteTo.Console(new CompactJsonFormatter())
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
이제 로그에 몇 가지 정보를 추가하도록 하겠습니다. 이 정보는 나중에 요청 시퀀스 다이어그램을 작성하는 데 사용할 것입니다. 이를 위해 ASP.NET Core 미들웨어를 생성하고 요청 처리 파이프라인에 정보를 추가하도록 하겠습니다.
주요 코드는 다음과 같습니다:
public async Task Invoke(HttpContext context)
{
GetCorrelationId(context);
GetInitialsService(context);
GetPreviousService(context);
GetPreviousClock(context);
using (LogContext.PushProperty(Names.CurrentServiceName,
ServiceNameProvider.ServiceName))
using (LogContext.PushProperty(Names.CorrelationIdHeaderName,
_correlationIdProvider.GetCorrelationId()))
using (LogContext.PushProperty(Names.InitialServiceHeaderName,
_initialServiceProvider.GetInitialService()))
using (LogContext.PushProperty(Names.PreviousServiceHeaderName,
_previousServiceProvider.GetPreviousService()))
using (LogContext.PushProperty(Names.RequestClockHeaderName,
_requestClockProvider.GetPreviousServiceClock()))
{
await _next(context);
}
}
- 현재 서비스의 이름입니다. 이 정보에는 마법이 없습니다. 어셈블리 이름 또는 원하는 어떤 것이 될 수 있을 것입니다.
- Correlation id(상관 ID). 긴 소개가 필요하지 않기를 바랍니다. 해당 아이디는 단일 외부 요청과 관련된 모든 로그 항목을 연결해 줍니다.
- 외부 요청이 전송되는 서비스의 이름입니다. 요청이 시스템에 들어오는 지점입니다. 이 정보는 편의상 제공되며 이 문서에서는 사용되지 않을 것입니다.
- 요청 체인의 이전 서비스 이름입니다. 요청이 어디에서 왔는지 아는 것에 유용합니다.
- 다른 서비스의 물리적 시간에 의존하지 않는 일종의 타임스탬프. 이에 대해서는 나중에 자세히 설명하겠습니다.
요청 처리 시작 시 우리는 요청 객체에서 이러한 모든 정보 값들을 가져와야 합니다. 이것이 Invoke 시작 시 GetNNN 메서드가 이러한 모든 작없을 수행 합니다.
GetCorrelationId 메서드를 살펴봅시다. 다른 메서드들도 일반적으로 동일합니다.
private void GetCorrelationId(HttpContext context)
{
if (context.Request.Headers.ContainsKey(Names.CorrelationIdHeaderName)
&& context.Request.Headers[Names.CorrelationIdHeaderName].Any())
{
_correlationIdProvider.SetCorrelationId
(context.Request.Headers[Names.CorrelationIdHeaderName][0]);
}
else
{
_correlationIdProvider.SetCorrelationId(Guid.NewGuid().ToString("N"));
}
}
이러한 값의 공급자도 일반적으로 동일합니다. 요청 동안 AsyncLocal<T> 유형의 필드에 값을 저장합니다:
public class CorrelationIdProvider
{
private static readonly AsyncLocal<string> Value = new();
public string GetCorrelationId()
{
var value = Value.Value;
if (string.IsNullOrWhiteSpace(value))
{
value = Guid.NewGuid().ToString("N");
SetCorrelationId(value);
}
return value;
}
public void SetCorrelationId(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Value cannot be null or whitespace.",
nameof(value));
Value.Value = value;
}
}
하지만 이 단순성에는 예외가 있습니다. 바로 이 단조로운 시간에 대해 이야기하고 있는 것입니다.
그럼 이 시간의 문제에 대해서 논의할 시간이 된 것 같군요.
Monotonous Sequence of Requests
단조로운 요청들의 시퀀스
기술적으로 각 로그 항목에는 자체 타임스탬프를 가지고 있습니다. 이 이러한 레코드 시퀀스를 고려하여 타임스탬프로 정렬하지 못하는 이유는 무엇일까요? 여기에는 몇 가지 장애물이 존재 합니다.
우선, 이미 말했듯이 다른 서비스들의 시간이 서로 동기화되지 않았을 수 있습니다. 수십 밀리초 정도의 작은 변화에도 로그 항목이 뒤섞여 버릴 수도 있습니다.
하지만 시계가 완벽하게 동기화되더라도 문제가 완전히 해결되지는 않습니다. 내 서비스가 매번 약간 다른 매개변수를 사용하여 동일한 엔드포인트를 여러 번 호출한다고 상상해 보십시오. 그리고 성능 향상을 위해 병렬처리 되고 있습니다. 이러한 호출의 로그 항목은 필연적으로 섞여 있을 것입니다.
우리는 이 문제에 대해서 어떻게 접근 할 수가 있을까요? 우리는 모든 서비스에 대해 동일한 마법의 단조로운 시간 또는 단조롭게 증가하는 일련의 숫자가 필요합니다. 우리는 이렇게 하여 다음과 같이 사용할 수 있습니다. 내 시스템이 요청을 받으면 이 시간를 0으로 설정합니다. 다른 서비스를 호출해야 할 때 시계 값을 1씩 증가시키고 이 신규 값을 다른 서비스로 보냅니다.
값을 가져와 자체적으로 호출될 때 매번 이 값을 증가시킵니다. 결국 마지막 값을 나에게 반환되겠죠. 이 값으로 나는 작업을 계속하면 됩니다.
이 접근 방식에는 여러 가지 단점이 있습니다. 우선, 외부(내 것이 아닌) 시스템은 내 시간 값을 업데이트하지 않습니다. 그러나 이 부분은 그렇게 중요하지 않습니다. 더 나쁜 부분은 바로 병렬 호출을 할 수 없다는 것입니다. 업데이트된 시간 값을 얻으려면 다음 호출이 끝날 때까지 기다려야 한다는 것이죠.
이 문제를 방지하기 위해 다른 방법을 사용해 볼 것입니다. 이전 서비스의 시간 값을 내 서비스의 시간 값에 대한 접두사로 사용하는 것입니다. 접미사는 다른 서비스에 대한 각 요청과 함께 단조롭게 증가하는 숫자됩니다. 구현 방법은 다음과 같습니다:
public class RequestClockProvider
{
private class ClockHolder
{
public string PreviousServiceClock { get; init; }
public int CurrentClock { get; set; }
}
private static readonly AsyncLocal<ClockHolder> Clock = new();
public void SetPreviousServiceClock(string? value)
{
Clock.Value = new ClockHolder
{
PreviousServiceClock = value ?? string.Empty
};
}
public string GetPreviousServiceClock() =>
Clock.Value?.PreviousServiceClock ?? string.Empty;
public string GetNextCurrentServiceClock()
{
lock (this)
{
var clock = Clock.Value!;
return $"{clock.PreviousServiceClock}.{clock.CurrentClock++}";
}
}
}
SetPreviousServiceClock 메서드는 내 미들웨어에서 이 요청에 대한 시간을 초기화하는 데 사용됩니다. GetNextCurrentServiceClock 메서드는 다른 서비스에 요청을 보낼 때마다 사용됩니다.
그러면, 만약 내 서비스 시간이 2로 설정된 요청을 받으면 시계 값이 2.0, 2.1, 2.2, ...인 다른 서비스에 대한 요청을 생성하고 서비스가 시계 값이 2.1인 요청을 받으면 값이 2.1.0, 2.1.1, 2.1.2, ...인 요청을 생성 할 것입니다.
만약 각 로그 항목에 대해 이러한 값이 있으면 쉽게 그룹화하고 순서를 지정할 수 있을 것입니다. 동일한 그룹 내의 항목은 하나의 서비스에서 하나의 요청을 처리할 때 생성되므로 타임스탬프로 안전하게 정렬할 수 있습니다. 이는 그들의 타임스탬프가 하나의 물리적 시간에 의해 생성되었음을 의미합니다.
한 가지 더 생각해 볼 점은, 여기서 Lamport 타임스탬프를 구현하고 있다고 말할 수 있습니다. 그럴 수도 있겠지만 감히 주장하지는 않겠습니다. 물론 이 문제를 해결하는 더 효율적인 알고리즘이 있다고 확신합니다. 실제로는 그것들을 사용해야 것입니다. 하지만 여기 나의 구현은 충분합니다.
요청 보내기
이제 요청에서 서비스에 대한 정보를 얻었습니다. 각각의 우리 자신만의 요청을 더 보낼 필요가 있습니다. 어떻게 할 수 있을까요? 단순하게, HttpClient의 인스턴스를 사용 할 것입니다. 그 서비스 중 하나인 내 client는 다음과 같습니다:
public interface IService2Client
{
Task Get();
}
public class Service2Client : IService2Client
{
private readonly HttpClient _client;
public Service2Client(HttpClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task Get()
{
_ = await _client.GetAsync("http://localhost:5106/weatherforecast");
}
}
다음과 같이 종속성 컨테이너에 등록 할 것입니다:
builder.Services.AddHttpClientWithHeaders<IService2Client, Service2Client>();
여기서 AddHttpClientWithHeaders 메서드는 다음 코드와 비슷할 것입니다:
public static IHttpClientBuilder AddHttpClientWithHeaders<TInterface, TClass>
(this IServiceCollection services)
where TInterface : class
where TClass : class, TInterface
{
return services.AddHttpClient<TInterface, TClass>()
.AddHttpMessageHandler<RequestHandler>();
}
코드에서 보다시피 나만의 요청 핸들러를 추가하고 있습니다. 코드 입니다:
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var requestClockValue = _requestClockProvider.GetNextCurrentServiceClock();
request.Headers.Add(Names.CorrelationIdHeaderName,
_correlationIdProvider.GetCorrelationId());
request.Headers.Add(Names.InitialServiceHeaderName,
_initialServiceProvider.GetInitialService());
request.Headers.Add(Names.PreviousServiceHeaderName,
ServiceNameProvider.ServiceName);
request.Headers.Add(Names.RequestClockHeaderName, requestClockValue);
using (LogContext.PushProperty(Names.RequestBoundaryForName, requestClockValue))
using (LogContext.PushProperty(Names.RequestURLName,
$"{request.Method} {request.RequestUri}"))
{
_logger.LogInformation("Sending request...");
return base.SendAsync(request, cancellationToken);
}
}
먼저 요청에 당신이 이미 알고 있는 값을 가진 여러 헤더를 추가합니다. 여기에서 이 값을 다음 서비스에 전달합니다.
그런 다음 두 개의 특수 필드가 있는 추가 로그 항목을 만듭니다. 그 중 하나는 요청 URL입니다. 이 정보는 정보용으로만 보관합니다. 나는 시퀀스 다이어그램에서 이 주소를 표시 할 것입니다. 두 번째 필드(RequestBoundaryFor)는 대상 서비스의 로그 항목을 어디에 배치해야 하는지 이해하는 데 사용됩니다. 나중에 요청 시퀀스 다이어그램 생성에 대해 이야기할 때 이 주제에 대해 논의할 것입니다.
시스템 시작
이제 요청을 해 볼 차례군요. 먼저 Docker Compose를 사용하여 Seq를 시작하겠습니다:
> docker compose -f "docker-compose.yml" up -d
그런다음 모든 서비스를 시작하겠습니다. 다음은 Visual Studio의 시작 구성입니다:
이제 서비스 중 하나(예: http://localhost:5222/weatherforecast) 에 요청 할 수 있을 것입니다.
그런 다음 우리는 Seq에 몇 가지 항목을 볼 수 있을 것입니다:
나는 이 정보들로 부터 correlation (상관 관계) ID 만 필요합니다.
이러한 로그 항목을 기반으로 요청 시퀀스 다이어그램을 어떻게 작성하는 지 살펴봅시다.
Request Sequence Diagram 구축
인터넷에는 무료 www.websequencediagrams.com 서비스가 있습니다. 이 서비스에는 시퀀스 다이어그램을 표시를 위한 자체 언어가 존재 합니다. 우리는 이 언어를 사용하여 요청을 표시 할 것입니다. 이러한 이유로 EventsReader 애플리케이션을 만들어 보았습니다.
그러나 먼저 Seq에서 로그 항목을 가져와야 합니다. Seq.Api NuGet 패키지를 사용 해야 할 것입니다:
using EventsReader;
using Seq.Api;
var connection = new SeqConnection("http://localhost:9090");
var result = connection.Events.EnumerateAsync(
filter: "CorrelationId = '4395cd986c9e4b548404a2aa2aca6016'",
render: true,
count: int.MaxValue);
var logs = new ServicesRequestLogs();
await foreach (var evt in result)
{
logs.Add(evt);
}
logs.PrintSequenceDiagram();
ServicesRequestLogs 클래스에서 우리의 모든 로그 항목을 monotonous clock 값으로 그룹화합니다:
public void Add(EventEntity evt)
{
var clock = evt.GetPropertyValue(Names.RequestClockHeaderName);
if(clock == null) return;
var singleServiceLogs = GetSingleServiceLogs(clock, evt);
singleServiceLogs.Add(evt);
}
private SingleServiceRequestLogs GetSingleServiceLogs(string clock, EventEntity evt)
{
if (_logRecords.ContainsKey(clock))
{
return _logRecords[clock];
}
var serviceName = evt.GetPropertyValue(Names.CurrentServiceName)!;
var serviceAlias = GetServiceAlias(serviceName);
var logs = new SingleServiceRequestLogs
{
ServiceName = serviceName,
ServiceAlias = serviceAlias,
Clock = clock
};
_logRecords.Add(clock, logs);
return logs;
}
private string GetServiceAlias(string serviceName)
{
if(_serviceAliases.ContainsKey(serviceName))
return _serviceAliases[serviceName];
var serviceAlias = $"s{_serviceAliases.Count}";
_serviceAliases[serviceName] = serviceAlias;
return serviceAlias;
}
동일한 값을 가진 모든 항목은 하나의 서비스에 의한 하나의 요청 처리에 해당합니다. 이 항목들은 간단한 클래스 하나에 저장합니다:
public class SingleServiceRequestLogs
{
public string ServiceName { get; set; }
public string ServiceAlias { get; set; }
public string Clock { get; set; }
public List<EventEntity> LogEntities { get; } = new List<EventEntity>();
public void Add(EventEntity evt)
{
LogEntities.Add(evt);
}
}
이제 우리는 시퀀스 다이어그램에 대한 설명을 구성할 것입니다:
public void PrintSequenceDiagram()
{
Console.WriteLine();
PrintParticipants();
PrintServiceLogs("");
}
PrintParticipants 메서드는 통신의 모든 참가자를 설명합니다. 서비스 이름에는 websequencediagrams에서 허용되지 않는 문자가 포함될 수 있으므로 별칭을 사용합니다:
private void PrintParticipants()
{
Console.WriteLine("participant \"User\" as User");
foreach (var record in _serviceAliases)
{
Console.WriteLine($"participant \"{record.Key}\" as {record.Value}");
}
}
PrintServiceLogs 메서드는 하나의 서비스에서 요청 처리 순서를 인쇄합니다. 이 메서드는 monotonous clock의 값을 매개변수로 갖습니다:
private void PrintServiceLogs(string clock)
{
var logs = _logRecords[clock];
if (clock == string.Empty)
{
Console.WriteLine($"User->{logs.ServiceAlias}: ");
Console.WriteLine($"activate {logs.ServiceAlias}");
}
foreach (var entity in logs.LogEntities.OrderBy
(e => DateTime.Parse(e.Timestamp, null,
System.Globalization.DateTimeStyles.RoundtripKind)))
{
var boundaryClock = entity.GetPropertyValue(Names.RequestBoundaryForName);
if (boundaryClock == null)
{
Console.WriteLine($"note right of {logs.ServiceAlias}:
{entity.RenderedMessage}");
}
else
{
if (_logRecords.TryGetValue(boundaryClock, out var anotherLogs))
{
Console.WriteLine($"{logs.ServiceAlias}->{anotherLogs.ServiceAlias}:
{entity.GetPropertyValue(Names.RequestURLName)}");
Console.WriteLine($"activate {anotherLogs.ServiceAlias}");
PrintServiceLogs(boundaryClock);
Console.WriteLine($"{anotherLogs.ServiceAlias}->{logs.ServiceAlias}: ");
Console.WriteLine($"deactivate {anotherLogs.ServiceAlias}");
}
else
{
// Call to external system
Console.WriteLine($"{logs.ServiceAlias}->External:
{entity.GetPropertyValue(Names.RequestURLName)}");
Console.WriteLine($"activate External");
Console.WriteLine($"External->{logs.ServiceAlias}: ");
Console.WriteLine($"deactivate External");
}
}
}
if (clock == string.Empty)
{
Console.WriteLine($"{logs.ServiceAlias}->User: ");
Console.WriteLine($"deactivate {logs.ServiceAlias}");
}
}
여기에서 이 특정 시간 값(logs 변수)에 대한 모든 로그 항목을 가져옵니다. 그런 다음 메서드의 시작과 끝에 다이어그램을 더 아름답게 만드는 코드가 만들 것입니다. 여기서 더 중요한 것이 없습니다:
if (clock == string.Empty) ...
모든 주요 작업은 foreach 루프 내에서 수행됩니다. 보시다시피 타임스탬프를 기준으로 로그 항목을 정렬합니다. 이러한 모든 항목은 하나의 서비스에서 하나의 요청을 처리한 결과로 얻어지기 때문에 여기에서 안전하게 진행 할 수 있습니다. 이는 이러한 모든 타임스탬프가 하나의 물리적 시간에서 온 것임을 의미합니다:
foreach (var entity in logs.LogEntities.OrderBy
(e => DateTime.Parse(e.Timestamp, null,
System.Globalization.DateTimeStyles.RoundtripKind))) ...
그런 다음 현재 항목이 다른 서비스에 대한 일부 요청의 시작을 나타내는 서비스 항목인지 확인합니다. 이미 말했듯이 이 항목에는 RequestBoundaryFor 필드가 포함되어야 합니다. 이 필드가 있으면 정상적인 항목입니다. 이 경우에 우리는 이 메시지를 메모로 인쇄합니다:
Console.WriteLine($"note right of {logs.ServiceAlias}: {entity.RenderedMessage}");
항목이 서비스 항목인 경우 두 가지 변형이 가능합니다.
우선, 다른 서비스에 대한 요청의 시작일 수 있습니다. 이 경우 로그에는 대상 서비스의 정보가 포함 될 것입니다. 우리는 이 정보를 추출하여 다이어그램에 추가합니다:
Console.WriteLine($"{logs.ServiceAlias}->{anotherLogs.ServiceAlias}:
{entity.GetPropertyValue(Names.RequestURLName)}");
Console.WriteLine($"activate {anotherLogs.ServiceAlias}");
PrintServiceLogs(boundaryClock);
Console.WriteLine($"{anotherLogs.ServiceAlias}->{logs.ServiceAlias}: ");
Console.WriteLine($"deactivate {anotherLogs.ServiceAlias}");
둘째, 외부 서비스에 대한 요청일 수 있습니다. 이 경우 해당 작업에 대한 로그 항목이 없습니다:
Console.WriteLine($"{logs.ServiceAlias}->External:
{entity.GetPropertyValue(Names.RequestURLName)}");
Console.WriteLine($"activate External");
Console.WriteLine($"External->{logs.ServiceAlias}: ");
Console.WriteLine($"deactivate External");
다 끝났습니다!
correlation(상관 관계) ID를 Program.cs 의 적절한 위치에 삽입하고 프로그램을 실행할 수 있습니다.
www.websequencediagrams.com 에 삽입할 수 있는 시퀀스 다이어그램에 대한 설명을 만들었습니다.
시스템 향상시키기
시스템이 준비되었습니다. 마지막으로 가능한 개선 사항에 대해 몇 마디 말하고 싶네요.
첫째, 여기서는 메시지 대기열(RabbitMQ, Azure EventHub 등)에 대한 메시지에 대해 이야기하지 않았습니다. 일반적으로 메시지와 함께 일부 메타데이터를 보낼 수 있으므로 특수 데이터(상관 ID, 단조로운 시계 값 등)를 전송할 수 있습니다. 메시지 대기열 지원은 우리 메커니즘의 자연스러운 다음 확장이 되어야 합니다.
둘째, www.websequencediagrams.com (최소한 무료 버전)의 기능은 그다지 크지 않습니다.
예를 들어 다양한 유형(정보, 경고, 오류 등)의 로그 항목을 시각적으로 구분하고 원할 수도 있을 것입니다. 아마도 우리는 시퀀스 다이어그램을 생성하기 위해 또 다른 더 강력한 도구를 사용할 수 있습니다.
셋째, 일부 요청은 "fire and forget(실행 후 잊어버리기)"로 전송됩니다. 아무도 이에 대한 완료를 기다리고 있지 않다는 것을 의미합니다. 우리는 어떻게든 다이어그램에서 다른 방식으로 표현되어야 합니다.
결론
그게 내가 말하고 싶었던 전부입니다. 이 기사가 귀하에게 유용하고 복잡한 시스템에서 일어나는 일을 이해하는 데 도움이 되기를 바랍니다. 행운을 빌어요~~
내 블로그에서 더 많은 기사를 읽을 수 있습니다.
History
27th October, 2022: Initial version
License
This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)
'프로그래밍' 카테고리의 다른 글
C++ 심층 네트워크 프로그래밍 (0) | 2022.12.13 |
---|---|
당신 손안에 사이언스 소프트웨어 (0) | 2022.12.12 |
뮤텍스, 세마포어 그리고 끔찍한 헤어스타일 사이 (0) | 2022.12.10 |
데이터 사이언스을 위한 C/C++ (1) | 2022.12.08 |
테스트 오케스트레이션: What, Why, and How? (0) | 2022.12.06 |