질문)
Win Form 안에 폰트를 가진 컨트롤이 여러개 있을떄에 폰트를 설정하지 않았을경우에, 어떤 폰트를 가지게 되나요?

답변)

WinForms에서는 컨트롤마다 폰트를 따로 지정하지 않았다고 해서 폰트가 없는 상태가 되는 것은 아닙니다. 기본적으로 부모 컨트롤의 Font를 상속(inherit) 받습니다.

예를 들어

 
Form
 ├─ Panel
 │   ├─ Label1
 │   ├─ Label2
 │   └─ Button1
 └─ TextBox1
 

Form의 폰트를:

 
this.Font = new Font("맑은 고딕", 12F);
 

로 지정하고 각각의 컨트롤에는 Font를 지정하지 않았다면:

Form
 ↓
맑은 고딕 12
 ↓
Panel
 ↓
Label1     → 맑은 고딕 12
Label2     → 맑은 고딕 12
Button1    → 맑은 고딕 12
 

처럼 부모의 Font를 물려받습니다.


그런데 중요한 부분이 있습니다

WinForms 컨트롤의 Font 속성을 보면 보통 디자이너에서:

Font = [System.Drawing.Font]
 

처럼 보이더라도 실제로는 Control.Font가 Ambient Property(주변 속성) 방식으로 부모에게서 값을 상속받을 수 있습니다.

즉:

 
label1.Font
 

을 호출했을 때 반드시

"Label 자체에 저장된 Font"

를 의미하는 것은 아닙니다.

부모로부터 상속받은 최종 Font가 반환될 수 있습니다.


그럼 Form에도 Font를 설정하지 않았다면?

여기가 질문의 핵심일 것 같습니다.

예를 들어:

 
public Form1()
{
    InitializeComponent();
}
 

이고

 
this.Font
 

도 별도로 설정하지 않았다면, WinForms의 기본 Font가 사용됩니다.

Windows/WinForms 환경에서는 일반적으로 시스템 기본 UI 폰트를 기준으로 하며, 현대 Windows에서는 Segoe UI 계열이 기본인 경우가 많습니다.

다만 OS 버전, DPI, WinForms 런타임/테마 등의 환경에 따라 실제 기본 Font가 달라질 수 있으므로 "항상 Segoe UI다"라고 고정해서 생각하면 안 됩니다.


실제로 확인하려면

현재 프로그램에서 다음 코드를 실행해 보면 됩니다.

 
MessageBox.Show(
    "Form Font : " + this.Font.Name +
    "\nSize : " + this.Font.Size);
 

또는:

 
MessageBox.Show(
    "Label Font : " + label1.Font.Name +
    "\nSize : " + label1.Font.Size);
 

예를 들어:

Form Font : Microsoft Sans Serif
Size : 8.25
 

처럼 나올 수도 있고,

환경에 따라:

Segoe UI
9
 

등으로 나올 수도 있습니다.


부모-자식 관계가 중요합니다

예를 들어:

 
this.Font = new Font("맑은 고딕", 10F);

panel1.Font = new Font("굴림", 12F);

// label1은 Font를 설정하지 않음
 

이라면:

Form
└─ Font = 맑은 고딕 10

   Panel
   └─ Font = 굴림 12

      Label
      └─ Font = 굴림 12  ← Panel에서 상속
 

입니다.

가장 가까운 부모의 Font를 따라갑니다.


반대로 Label에 직접 Font를 지정하면

 
this.Font = new Font("맑은 고딕", 10F);

panel1.Font = new Font("굴림", 12F);

label1.Font = new Font("Arial", 20F);
 

이면:

Form
 └─ 맑은 고딕 10
     │
     └─ Panel
          └─ 굴림 12
               │
               └─ Label
                    └─ Arial 20  ← 직접 지정
 

이 됩니다.


그리고 지금 하시는 WinForms 변환 작업에서는 이 부분이 상당히 중요합니다.

기존 C1 컨트롤이나 다른 서드파티 컨트롤에서 컨트롤별 Font가 개별적으로 저장되어 있었다면, DataGridView, TreeView 등으로 바꾸면서 Font를 별도로 지정하지 않았을 때 부모 Form의 Font를 따라가면서 화면이 달라질 수 있습니다.

그래서 기존 화면과 동일하게 만들려면 단순히:

 
dataGridView1.Font = ...
 

만 보는 게 아니라,

Form.Font
   ↓
Panel.Font
   ↓
컨트롤.Font
 

Font 상속 구조 자체를 확인하는 것이 좋습니다.

질문)
BeginInit() / EndInit()을 생략한 경우와 생략하지 않았을경우에 극단적으로 차이를 보여주는 예제코드를 작성해주세요.

보통 WinForms 디자이너가 자동으로 넣어줍니다

예를 들어 어떤 컨트롤이 ISupportInitialize를 구현하고 있고, Visual Studio 디자이너가 해당 컨트롤을 초기화할 때 필요하다고 판단하면 .Designer.cs에 다음과 같이 생성합니다.

 
((System.ComponentModel.ISupportInitialize)(this.someControl)).BeginInit();

this.someControl.Location = new System.Drawing.Point(10, 10);
this.someControl.Name = "someControl";
this.someControl.Size = new System.Drawing.Size(200, 100);

((System.ComponentModel.ISupportInitialize)(this.someControl)).EndInit();
 

즉, 직접 매번 작성하는 것이 아닙니다.

특히 디자이너에서 컨트롤을 배치하고 속성을 변경하면 Visual Studio가 필요한 경우 자동으로 .Designer.cs에 넣어줍니다.


그래서 제가 한 문장으로 표현한다면:

BeginInit() / EndInit()의 존재 이유는 "초기화 작업을 하나의 원자적인 초기화 과정처럼 처리하기 위해서"이고, 그 결과로 불필요한 반복 처리와 처리시간을 줄일 수 있다.


답변)

BeginInit() / EndInit()의 차이가 극단적으로 보이도록 하려면, 실제 WinForms 컨트롤보다 ISupportInitialize를 직접 구현한 테스트 클래스를 만드는 것이 가장 명확합니다.

아래 예제에서는 BeginInit()이 없으면 설정할 때마다 무거운 작업을 실행하고, BeginInit() / EndInit()을 사용하면 마지막에 딱 한 번만 실행하도록 만들어 보겠습니다.

예제 1 — 차이가 극단적으로 보이는 코드

 
using System;
using System.ComponentModel;
using System.Diagnostics;

public class MyControl : ISupportInitialize
{
    private bool _initializing = false;

    private int _width;
    private int _height;
    private string _text;
    private int _value;

    public void BeginInit()
    {
        _initializing = true;

        Console.WriteLine(">>> BeginInit()");
    }

    public void EndInit()
    {
        _initializing = false;

        Console.WriteLine(">>> EndInit()");

        // 최종 설정이 끝났으므로 한 번만 실행
        HeavyWork();
    }

    public int Width
    {
        get { return _width; }
        set
        {
            _width = value;

            if (!_initializing)
                HeavyWork();
        }
    }

    public int Height
    {
        get { return _height; }
        set
        {
            _height = value;

            if (!_initializing)
                HeavyWork();
        }
    }

    public string Text
    {
        get { return _text; }
        set
        {
            _text = value;

            if (!_initializing)
                HeavyWork();
        }
    }

    public int Value
    {
        get { return _value; }
        set
        {
            _value = value;

            if (!_initializing)
                HeavyWork();
        }
    }

    private void HeavyWork()
    {
        Console.WriteLine(
            "HeavyWork 실행 : " +
            "Width=" + _width +
            ", Height=" + _height +
            ", Text=" + _text +
            ", Value=" + _value);

        // 극단적인 차이를 만들기 위한 가상의 무거운 작업
        System.Threading.Thread.Sleep(500);
    }
}
 

이제 생략한 경우를 보겠습니다.


1. BeginInit() / EndInit()을 생략

 
static void TestWithoutInit()
{
    MyControl control = new MyControl();

    Stopwatch sw = Stopwatch.StartNew();

    control.Width = 100;
    control.Height = 200;
    control.Text = "Hello";
    control.Value = 100;

    sw.Stop();

    Console.WriteLine();
    Console.WriteLine(
        "소요 시간 = " + sw.ElapsedMilliseconds + " ms");
}
 

실행하면 대략:

HeavyWork 실행 : Width=100, Height=0, Text=, Value=0
HeavyWork 실행 : Width=100, Height=200, Text=, Value=0
HeavyWork 실행 : Width=100, Height=200, Text=Hello, Value=0
HeavyWork 실행 : Width=100, Height=200, Text=Hello, Value=100

소요 시간 = 2000 ms
 

즉,

Width 설정
    ↓
HeavyWork()

Height 설정
    ↓
HeavyWork()

Text 설정
    ↓
HeavyWork()

Value 설정
    ↓
HeavyWork()
 

총 4번 실행됩니다.


2. BeginInit() / EndInit() 사용

이번에는:

 
static void TestWithInit()
{
    MyControl control = new MyControl();

    Stopwatch sw = Stopwatch.StartNew();

    control.BeginInit();

    control.Width = 100;
    control.Height = 200;
    control.Text = "Hello";
    control.Value = 100;

    control.EndInit();

    sw.Stop();

    Console.WriteLine();
    Console.WriteLine(
        "소요 시간 = " + sw.ElapsedMilliseconds + " ms");
}
 

실행하면:

>>> BeginInit()

>>> EndInit()

HeavyWork 실행 : Width=100, Height=200, Text=Hello, Value=100

소요 시간 = 500 ms
 

이번에는:

BeginInit()
    ↓
Width 설정       ─┐
Height 설정       │
Text 설정         ├── HeavyWork 실행 안 함
Value 설정        │
                  │
EndInit()         ─┘
    ↓
HeavyWork()       ← 딱 한 번
 

따라서 극단적으로 비교하면:

방식HeavyWork 횟수예상 시간
생략 4회 약 2초
사용 1회 약 0.5초

설정하는 속성이 100개라면:

BeginInit/EndInit 없음
→ HeavyWork 100회
→ 약 50초

BeginInit/EndInit 사용
→ HeavyWork 1회
→ 약 0.5초
 

이렇게까지 차이가 날 수 있습니다.


그런데 실제 WinForms에서는 조금 다릅니다

여기가 중요합니다.

실제 ISupportInitialize 컨트롤이 반드시 위와 같이 동작한다는 뜻은 아닙니다.

BeginInit() / EndInit()의 정확한 동작은 그 컨트롤을 만든 개발자가 어떻게 구현했느냐에 따라 달라집니다.

예를 들어 실제 컨트롤 내부가:

 
public void BeginInit()
{
    _initializing = true;
}

public void EndInit()
{
    _initializing = false;
    Rebuild();
}
 

처럼 만들어져 있을 수도 있고,

 
public void BeginInit()
{
    // 아무 것도 안 함
}

public void EndInit()
{
    // 아무 것도 안 함
}
 

일 수도 있습니다.

따라서

ISupportInitialize를 구현했다 = BeginInit/EndInit을 빼면 반드시 문제가 발생한다

는 것은 아닙니다.


가장 중요한 핵심

BeginInit() / EndInit()의 본질은 "초기 설정을 하는 동안 컨트롤에게 아직 최종 상태가 아니라고 알려주는 것"입니다.

예를 들어 디자이너가 다음처럼 20개의 속성을 설정한다고 생각해 보겠습니다.

 
control.Width = 100;
control.Height = 200;
control.Text = "TEST";
control.Value = 10;
control.Font = ...;
control.Color = ...;
control.DataSource = ...;

// ...
 

이때 컨트롤 입장에서:

Width = 100
    ↓
"아직 초기화 중이네"

Height = 200
    ↓
"아직 초기화 중이네"

Text = TEST
    ↓
"아직 초기화 중이네"

...

마지막
    ↓
EndInit()
    ↓
"이제 설정 끝났구나!"
    ↓
실제 초기화
 

라는 구조를 만들 수 있습니다.


그리고 BeginUpdate()와 비교하면 더 쉽게 이해됩니다

지금 작업하시는 TreeView의 경우:

 
treeview.BeginUpdate();

treeview.Nodes.Clear();

treeview.Nodes.Add(...);
treeview.Nodes.Add(...);
treeview.Nodes.Add(...);
treeview.Nodes.Add(...);

treeview.EndUpdate();
 

이것은:

화면 그리기를 잠시 중단했다가 한 번에 보여준다.

입니다.

반면:

 
control.BeginInit();

control.Width = 100;
control.Height = 200;
control.Text = "TEST";

control.EndInit();
 

이것은:

컨트롤의 초기 설정 작업을 하나의 초기화 과정으로 묶는다.

입니다.

즉,

BeginInit / EndInit
        ↓
"컨트롤 초기화"

BeginUpdate / EndUpdate
        ↓
"화면 업데이트"
 

라고 구분하면 거의 정확합니다.

한마디로 표현하면:

Windows에 설치된 하나의 Redis/MySQL 서비스를 Windows의 C# Client와 WSL Ubuntu의 APM Server가 TCP/IP를 통해 공동 사용한다.


1. WSL의 가상 네트워크

WSL Ubuntu는 Windows와 별도의 Linux 환경이지만, Windows Host와 네트워크로 연결되어 있습니다.

따라서 Ubuntu 입장에서는 Windows에서 실행 중인 Redis/MySQL을:

IP 주소 + Port
 

로 접근할 수 있습니다.

예를 들어:

Redis → Windows_IP:6379
MySQL → Windows_IP:3306
 

2. Redis/MySQL의 TCP 서버

Redis와 MySQL은 단순히 "Windows 프로그램"이 아닙니다.

핵심적으로는 TCP 서버입니다.

Redis
TCP Listen
0.0.0.0:6379 또는 특정 IP:6379
 
MySQL
TCP Listen
0.0.0.0:3306 또는 특정 IP:3306
 

따라서 접속하는 프로그램이 Windows 프로그램이든 Linux 프로그램이든 상관없습니다.

Windows C# ────────┐
WSL Ubuntu ────────┼──► TCP ──► Redis :6379
└──► TCP ──► MySQL :3306
 

Redis/MySQL이 어떤 OS에서 실행되고 있는지는 클라이언트 입장에서는 중요하지 않습니다.




설치하려는 ScottPlot.WinForms 5.1.59는 .NET Framework 4.6.1을 지원하지 않습니다.

따라서 이전에 제가 말씀드린 "4.6.2 이상도 지원"이라는 부분은 현재 설치하려는 ScottPlot 5.1.59 기준으로는 정확하지 않았습니다.

가장 적절한 해결 방법

현재 프로젝트를 가능하다면:

.NET Framework 4.6.1
.NET Framework 4.8
 

로 올리는 것을 가장 추천합니다.

특히 지금 프로젝트가 기존 C1Chart 기반의 오래된 WinForms 프로그램이므로, 당장 .NET 8로 전환하는 것보다 .NET Framework 4.8로 먼저 올리는 것이 훨씬 안전합니다.


1. 현재 프로젝트의 Target Framework 변경

Visual Studio에서:

대상 프레임워크
.NET Framework 4.6.1
 

.NET Framework 4.8
 

로 변경합니다.

프로젝트를 다시 로드하라는 메시지가 나오면 재로드합니다.


2. 다시 설치

Package Manager Console에서:

 
Install-Package ScottPlot.WinForms
 

실행합니다.

그러면 현재처럼

.NETFramework,Version=v4.6.1
 

이 아니라

.NETFramework,Version=v4.8
 

을 대상으로 패키지를 설치하게 됩니다.


 

만약 .NET Framework 4.6.1을 절대 변경할 수 없다면

그 경우에는 ScottPlot 5.1.59를 사용하면 안 됩니다.

ScottPlot의 구버전 중 현재 프로젝트와 호환되는 버전을 찾아야 합니다.

 

Windows 11에서는 .NET Framework 4.8 계열이 기본적으로 포함되어 있습니다. Microsoft 공식 문서에서도 Windows 11 21H2에는 4.8, 이후 버전에는 4.8.1이 포함되어 있다고 명시합니다.

중요한 점은 오프라인 설치 Windows 11이라는 조건입니다.

Windows 11 버전별

Windows 11기본 포함
21H2 (22000) .NET Framework 4.8
22H2 (22621) .NET Framework 4.8.1
23H2 (22631) .NET Framework 4.8.1
24H2 (26100) .NET Framework 4.8.1
25H2 (26200) .NET Framework 4.8.1

즉, Windows 11이라면 최소한 .NET Framework 4.8이 기본적으로 들어있다고 생각하셔도 됩니다.

그리고 아주 중요한 부분이 하나 있습니다.

4.8.1이 설치되어 있어도 4.8 프로젝트를 실행할 수 있습니다

.NET Framework 4.x는 In-place Update 방식이라 한 시스템에 4.6.1, 4.8, 4.8.1을 각각 독립적으로 설치하는 구조가 아닙니다. 4.8.1은 기존 4.x를 대체하며 4.8용으로 작성된 프로그램도 지원합니다.

따라서 현재 프로젝트를:

.NET Framework 4.6.1
.NET Framework 4.8
 

로 변경해도, Windows 11 배포 PC에서 별도의 .NET Framework 4.8 설치 프로그램을 반드시 실행해야 하는 것은 아닙니다.

다만 개발 PC에서 Visual Studio가 **.NET Framework 4.8 Developer Pack / Targeting Pack**을 필요로 할 수 있습니다. 이것은 런타임과 별개의 문제입니다. Microsoft도 개발 환경에서는 Developer Pack이 필요할 수 있다고 설명합니다.

지금 상황에서는

현재 APM이 .NET Framework 4.6.1이고 ScottPlot 5 설치가 실패했으므로 저는 다음 순서로 진행하는 것을 추천합니다.

현재 APM
.NET Framework 4.6.1
Visual Studio에서 Target Framework 4.8
Clean / Rebuild
Install-Package ScottPlot.WinForms
C1Chart → ScottPlot 변환
 

그리고 오프라인 배포를 고려한다면 4.8보다 4.8.1로 올릴 필요가 있는지도 별도로 판단할 수 있습니다. 현재 Windows 11이 주 대상이라면 4.8.1이 기본 포함되는 버전이 많기 때문에 4.8.1을 타겟으로 하는 것도 가능합니다.

Windows 11에서는 명령 프롬프트(CMD)에서 확인하는 방법(.NET Framework 4.8)

1. CMD에서 바로 확인

CMD를 열고 다음을 실행하세요.

 
reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Release
 

예를 들어:

Release REG_DWORD 0x81e...
 

처럼 나오면 됩니다.

다만 Release 숫자를 직접 해석하기 번거로우므로 다음 명령을 추천합니다.

2. PowerShell에서 버전까지 확인

PowerShell에서:

 
$release = Get-ItemPropertyValue "HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" -Name Release
if ($release -ge 533320) {
".NET Framework 4.8.1 이상"
}
elseif ($release -ge 528040) {
".NET Framework 4.8"
}
else {
".NET Framework 4.8 미만"
}
 

Microsoft 공식 기준으로 Release 값이 528040 이상이면 .NET Framework 4.8 이상입니다. Windows 11의 4.8은 일반적으로 Release 값 528449이고, 4.8.1은 Windows 11 버전에 따라 533320 또는 533509입니다.

3. 가장 간단하게 한 줄로

현재 PC가 4.8 이상인지 YES/NO만 확인하려면 CMD에서:

 
powershell -command "(Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -Name Release) -ge 528040"
 

결과가:

True
 

이면 .NET Framework 4.8 이상 설치입니다.


그리고 한 가지 정정할 부분이 있습니다. Windows 11 22H2 이상은 .NET Framework 4.8.1이 기본 포함되어 있습니다. 21H2는 4.8이 기본 포함입니다.

따라서 지금 사용하시는 PC에서 위 명령을 실행한 결과를 보여주시면, 현재 APM 프로젝트의 4.6.1을 4.8 또는 4.8.1 중 어느 것으로 올리는 게 좋은지 바로 판단해드릴 수 있습니다.

현재 코드에서는 BLPop()을 다시 호출할 때 기존 RedisClient의 연결을 통해 다시 통신을 시도합니다. 다만 "새로운 TCP 연결을 무조건 새로 만든다"와 "Redis 서버에 다시 접속을 시도한다"는 것은 구분해야 합니다.

현재 코드 흐름은:

 
_receiveRedis = new RedisClient(ipAddress, Convert.ToInt32(portNo));
 

이것은 Init()에서 처음 한 번만 실행됩니다.

그 이후에는 계속 같은 _receiveRedis 객체에 대해:

 
jsonBytes = _receiveRedis.BLPop(key, 10);
 

을 호출합니다.

그리고 Worker가 끝나면:

 
_recvWorker.RunWorkerAsync();
 

를 다시 호출합니다.

즉:

Init()
└─ new RedisClient()
_receiveRedis
├──── BLPop() ──→ 접속/통신
├──── BLPop() ──→ 다시 통신
├──── BLPop() ──→ 다시 통신
└──── BLPop() ──→ 다시 통신
 

중요한 점

RunWorkerAsync()가 다시 실행될 때:

 
new RedisClient(...)
 

를 다시 하는 것은 아닙니다.

따라서 코드상으로는:

❌ Worker 재시작
→ RedisClient 새로 생성
→ TCP 연결 새로 생성
 

이 아닙니다.

정확히는:

⭕ Worker 재시작
→ 기존 _receiveRedis 사용
→ BLPop() 호출
→ ServiceStack.Redis가 필요하면 연결/소켓을 다시 처리
 

입니다.

여기서 실제 TCP 소켓을 내부적으로 재사용하는지, 끊어진 소켓을 ServiceStack.Redis가 내부적으로 다시 연결하는지는 현재 올려주신 애플리케이션 코드만으로는 확정할 수 없습니다. 그 부분은 ServiceStack.Redis 라이브러리의 RedisClient 구현에 해당합니다.


그래서 현재 코드에서 확실하게 말할 수 있는 것은

Redis 서버가 죽어 있을 때

 
BLPop(key, 10)
 

→ 예외 발생
→ recvWorker_DoWork() 종료
→ recvWorker_RunWorkerCompleted() 실행
→ RunWorkerAsync()
→ 다시 BLPop(key, 10) 호출

이라는 반복 재시도 구조입니다.

따라서 서버가 복구되면 이후 BLPop() 호출에서 정상적으로 통신이 이루어질 가능성이 있습니다.

다만 "서버가 복구되면 반드시 새 TCP 연결을 맺는다"까지 현재 코드만으로 보장할 수는 없습니다.


그리고 한 가지 더 중요한 문제가 있습니다.

현재:

 
catch (Exception rExcep)
{
Log($"[RECV][ERROR][Exception] {rExcep}");
}
 

에서 Redis 접속 오류를 그냥 기록하고 끝냅니다.

그 후 Completed에서 무조건 다시 Worker를 시작합니다.

따라서 현재 구조는 사실상:

BLPop() → Redis 통신 실패 → 0초에 가깝게 다시 BLPop() → 실패 → 다시 BLPop()

이 될 수도 있습니다.

재접속 간격을 별도로 두는 Thread.Sleep()이나 backoff 로직은 현재 코드에는 없습니다.

현재 올려주신 코드에서 Redis 접속 재시도를 직접 하는 코드는 recvWorker_RunWorkerCompleted()에 있습니다.

핵심 코드는 이것입니다.

 
private void recvWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
try
{
...
 
if (!_recvWorker.IsBusy)
{
_recvWorker.RunWorkerAsync();
}
}
...
}
 

파일 기준으로는 549~557라인입니다.

실제 흐름

Init()에서 최초 1회:

 
_recvWorker.RunWorkerAsync();
 

가 실행됩니다.

그러면:

 
recvWorker_DoWork()
 

에서 실제 Redis 통신:

 
jsonBytes = _receiveRedis.BLPop(key, 10);
 

을 합니다.

접속 실패나 timeout 등의 이유로 recvWorker_DoWork()가 끝나면 RunWorkerCompleted가 호출됩니다.

그리고 바로:

 
if (!_recvWorker.IsBusy)
{
_recvWorker.RunWorkerAsync();
}
 

를 실행합니다.

즉:

Init()
└─ RunWorkerAsync()
recvWorker_DoWork()
_receiveRedis.BLPop(...)
실패/종료
recvWorker_RunWorkerCompleted()
RunWorkerAsync()
recvWorker_DoWork()
_receiveRedis.BLPop(...)
 

이렇게 반복됩니다.


다만 아주 중요한 차이가 있습니다

이 코드가 new RedisClient()를 다시 하는 재접속 코드는 아닙니다.

Init()에서 만들어진:

 
_receiveRedis = new RedisClient(ipAddress, Convert.ToInt32(portNo));
 

객체를 그대로 사용합니다.

따라서 정확히 표현하면:

RedisClient를 새로 생성해서 재접속하는 것이 아니라, 기존 _receiveRedis 객체를 사용하여 BLPop()을 다시 시도하는 구조입니다.

즉 recvWorker_RunWorkerCompleted()가 하는 것은 Worker 재실행이고, 그 결과 BLPop()이 다시 호출되는 것입니다.


Send 쪽도 재시도 구조가 있습니다

sendWorker_RunWorkerCompleted()에서:

 
if (sendQueue.Count > 0 && !_sendWorker.IsBusy)
_sendWorker.RunWorkerAsync();
 

를 합니다.

따라서 Send Queue에 데이터가 남아 있다면 다시 RPush()를 시도합니다.

하지만 Receive 쪽은 Queue와 관계없이 매번 재실행되는 것이 차이입니다.

Receive
BLPop 실패
→ Worker 종료
→ Completed
→ RunWorkerAsync()
→ BLPop 다시 시도
→ ...
 

이것이 현재 코드에서의 Redis 재시도 메커니즘입니다.

올려주신 전체 APMMultipleRedisManager 코드를 기준으로 보면, 앞서 말씀드린 것보다 정확하게 판단할 수 있습니다.

결론부터 말씀드리면:

현재 코드는 접속 불능 서버에 대해서도 recvWorker가 살아서 계속 재시도하는 구조입니다.

반면 sendWorker는 작업이 들어올 때만 실행되고, Redis 통신 실패 후에는 해당 작업이 끝나면서 Worker 자체는 종료됩니다. 이후 새로운 송신 요청이 들어오면 다시 실행됩니다.

특히 recvWorker는 상당히 명확하게 10초 단위로 계속 재실행되는 구조입니다.


1. Init() 자체는 대부분 정상적으로 끝날 가능성이 높습니다

현재:

 
_sendRedis = new RedisClient(ipAddress, Convert.ToInt32(portNo));
 

그리고:

 
_receiveRedis = new RedisClient(ipAddress, Convert.ToInt32(portNo));
 

를 수행합니다.

중요한 것은 Init()에서 실제 Redis 명령인 RPush()나 BLPop()을 실행하지 않는다는 것입니다.

따라서 단순히:

10.112.160.71:9111
 

이 접속 불가능하더라도 new RedisClient() 단계에서 바로 예외가 발생하지 않는다면:

 
_sendWorker.RunWorkerAsync();
_recvWorker.RunWorkerAsync();
 

까지 실행됩니다.

접속 불능이라고 해서 자동으로 해당 서버의 Worker가 생성되지 않는 구조는 아닙니다.


2. 특히 Receive Worker는 계속 살아있는 구조입니다

이 부분이 핵심입니다.

recvWorker_DoWork()에서:

 
jsonBytes = _receiveRedis.BLPop(key, 10);
 

을 합니다.

여기서 10은 BLPOP timeout입니다.

즉 정상적인 경우:

CLI:xxxx
BLPOP
├── 데이터 도착 → 데이터 처리
└── 10초 동안 데이터 없음 → NULL
 

입니다.

그런데 더 중요한 것은 recvWorker_RunWorkerCompleted()입니다.

 
if (!_recvWorker.IsBusy)
{
_recvWorker.RunWorkerAsync();
}
 

가 있습니다.

따라서:

recvWorker 실행
BLPOP(..., 10)
10초 timeout
recvWorker 종료
RunWorkerCompleted
RunWorkerAsync()
recvWorker 다시 실행
BLPOP(..., 10)
...
 

이 구조입니다.


3. 따라서 Redis 서버가 완전히 죽어 있어도?

예를 들어:

10.112.160.71:9111
 

Redis 서버가 아예 꺼져 있다고 하겠습니다.

그러면 대략:

Init()
├─ _sendRedis 생성
├─ sendWorker 시작
├─ _receiveRedis 생성
└─ recvWorker 시작
BLPop()
│ 연결 실패
Exception
recvWorker_DoWork 종료
RunWorkerCompleted
RunWorkerAsync()
다시 시도
 

가 됩니다.

실제로 recvWorker_DoWork()에는 Exception 처리가 있습니다.

 
catch (Exception rExcep)
{
Log($"[RECV][ERROR][Exception] {rExcep}");
}
 

그리고 finally로 종료합니다.

그 후 RunWorkerCompleted에서 다시 시작합니다.

따라서 Receive 측은

"접속이 안 되니까 Thread가 죽는다"

가 아니라,

"한 번의 Worker 실행은 끝나지만 Completed에서 다시 Worker를 시작한다"

가 정확한 표현입니다.


4. 실제로는 Thread가 계속 살아있는 것은 아닙니다

여기서 용어를 정확하게 구분해야 합니다.

현재 BackgroundWorker는:

 
_recvWorker.RunWorkerAsync();
 

하면 내부적으로 ThreadPool 등을 이용해서 작업을 수행합니다.

recvWorker_DoWork()가 끝나면 그 작업 스레드는 해당 작업을 끝냅니다.

그리고:

 
RunWorkerCompleted
RunWorkerAsync()
 

다음 작업을 다시 시작합니다.

따라서:

❌ 하나의 Thread가 계속 살아 있음
 
⭕ Worker 객체는 살아 있고,
작업이 끝날 때마다 새로운 작업을 다시 실행
 

이라고 보는 것이 정확합니다.


5. Receive 쪽의 실제 메커니즘

현재 코드에서는 사실상 이런 구조입니다.

APMMultipleRedisManager
_recvWorker
recvWorker_DoWork()
BLPop(key, 10)
┌────────────┴────────────┐
│ │
데이터 수신 예외/Timeout
│ │
▼ ▼
recvQueue.Enqueue catch 처리
│ │
▼ │
sendClientWorker 실행 │
│ │
└────────────┬────────────┘
recvWorker 종료
RunWorkerCompleted()
RunWorkerAsync()
다시 BLPOP
 

Receive는 지속적인 polling/retry 구조입니다.


6. 그런데 Send Worker는 다릅니다

Send Worker는:

 
private void sendWorker_DoWork(...)
 

에서:

 
if (sendQueue.Count > 0)
{
byte[] jsonByte = sendQueue.Dequeue();
 
...
 
resultCode = _sendRedis.RPush("QCS", jsonByte);
}
 

를 합니다.

그런데 이 메서드는 한 번에 Queue 하나만 처리합니다.

즉:

sendQueue
sendWorker
└─ 하나 꺼냄
RPush()
종료
 

입니다.

그리고 Completed에서:

 
if (sendQueue.Count > 0 && !_sendWorker.IsBusy)
_sendWorker.RunWorkerAsync();
 

를 합니다.

그래서 Send는:

SendQuery()
sendQueue.Enqueue()
Worker 실행
RPush()
성공/실패
Worker 종료
Queue가 남아 있으면 다시 Worker 실행
 

구조입니다.


7. 접속 불능일 때 Send Worker는 어떻게 되는가?

예를 들어:

 
resultCode = _sendRedis.RPush("QCS", jsonByte);
 

에서 네트워크 오류가 발생하면:

 
catch (Exception RequestExcep)
{
log4net.LogManager.GetLogger(this.GetType()).Info(
"sendWorker_DoWork.RPush=>[RedisException=" +
RequestExcep.ToString() + "]");
}
 

로 들어갑니다.

그 후 sendWorker_DoWork()는 종료됩니다.

그러면 RunWorkerCompleted()가 실행됩니다.

그런데 여기서는:

 
if (sendQueue.Count > 0 && !_sendWorker.IsBusy)
_sendWorker.RunWorkerAsync();
 

입니다.

따라서 Queue에 다른 데이터가 있으면 다시 실행됩니다.


8. 여기서 상당히 중요한 문제가 하나 있습니다

접속 불능 서버에서 SendQuery()가 계속 호출되면:

 
sendQueue.Enqueue(jsonByte);
 

가 계속 됩니다.

그런데 Redis가 안 되니까:

 
_sendRedis.RPush(...)
 

는 계속 실패합니다.

그러면:

SendQuery
sendQueue +1
Worker
RPush 실패
Worker 종료
Queue에 남아 있으면
Worker 재실행
RPush 실패
Worker 종료
...
 

가 됩니다.

따라서 접속 불능 상태에서 SendQuery가 계속 들어오면 sendQueue가 계속 증가할 가능성이 있습니다.

이 부분은 현재 코드에서 상당히 중요한 포인트입니다.


9. Receive 쪽도 특이한 부분이 있습니다

현재:

 
jsonBytes = _receiveRedis.BLPop(key, 10);
 

이므로 정상적인 Redis 연결 상태에서도 데이터가 없으면 10초마다 한 번씩 Worker가 종료/재실행됩니다.

즉 서버가 정상이어도:

10초
BLPOP timeout
Worker 종료
Completed
Worker 재실행
10초
...
 

입니다.

따라서 이것은 서버 장애 여부와 관계없이 원래 그렇게 설계된 코드입니다.


10. IsStopConsole도 주목해야 합니다

코드에:

 
if (waitMs > 9000)
{
APMMemory.GetInstance.IsStopConsole = true;
}
 

가 있습니다.

즉 BLPOP이 거의 10초 동안 기다리면:

IsStopConsole = true
 

로 설정합니다.

그런데 이 파일 안에서는 이 값을 다시 false로 만드는 것이 아니라 Init()에서:

 
APMMemory.GetInstance.IsStopConsole = false;
 

로 초기화하고 있습니다.

따라서 한 서버의 Receive Worker에서 BLPOP이 9초 이상 걸렸다는 이유만으로 전역 APMMemory.GetInstance.IsStopConsole이 true가 될 수 있습니다.

서버가 여러 개라면 이것도 확인할 필요가 있습니다.


최종적으로 현재 코드의 서버별 동작을 정리하면

상황Send WorkerReceive Worker
Redis 정상 작업 있을 때 실행 BLPOP 수행
Redis 정상 + 데이터 없음 종료 10초 후 종료 → 다시 실행
Redis 연결 불능 RPush 실패 → Worker 종료 BLPOP 예외 → Worker 종료
실패 후 재실행 Queue가 남아 있으면 재실행 항상 Completed에서 재실행
지속적인 Thread
지속적인 Worker 객체
자동 재시도 Queue가 있을 때 항상

따라서 질문하신 핵심에 대한 답은:

접속 불능이라고 해서 해당 서버의 APMMultipleRedisManager 자체가 죽는 것은 아닙니다.

특히 Receive 측은:

Worker 실행
→ Redis 접속 실패
→ 예외 처리
→ Worker 종료
→ RunWorkerCompleted
→ Worker 재실행
→ Redis 접속 재시도
→ 실패
→ 재실행
→ ...
 

이라는 자동 재시도 메커니즘이 이미 들어가 있습니다.

그리고 Send 측은 송신 Queue가 있는 동안 실패 → 재실행을 반복할 수 있습니다.

따라서 현재 프로그램은 "접속 불능 서버를 제거하는 방식"이 아니라 "접속 불능이어도 Worker를 유지하면서 계속 재시도하는 방식"에 가깝습니다.

특히 여러 서버 중 하나가 죽었을 때 전체 프로그램이 영향을 받는지, 또는 죽은 서버의 _recvWorker가 계속 재시도하면서 CPU/로그/네트워크에 부담을 주는지를 보려면 현재 구조에서 RunWorkerCompleted()의 재실행 부분이 핵심입니다.

결론

Redis QCS 응답 또는 SQL 데이터가 비정상적으로 변경된 것이 아니라, printf(reply->element[1]->str)와 sprintf(tmpText, reply->element[1]->str)에서 발생한 Format String 취약성/오용이 직접적인 원인으로 판단된다.

특히 SQL의 LIKE 'OTRMES%', LIKE 'dr\\_%', LIKE '%ME' 등의 % 문자가 printf()의 format specifier로 해석되면서 정의되지 않은 동작이 발생했고, 그 결과 0x0p+0, 6.951058E-310 등의 비정상적인 값이 삽입된 것처럼 출력된 것이다.

따라서 Redis 서버, hiredis, redisReply, SQL 생성부를 우선 의심할 문제가 아니라 출력 및 문자열 복사 과정의 printf/sprintf 사용법을 수정하는 것이 1차적인 해결책이다.




질문)

1. 올바른 출력
printf("%s", reply->element[1]->str);
결과)
instancename like 'OTRMES%'                                                 and monname in ('ME', 'MR')             )d                                 ON
dr_re.RESNAME like 'dr\\_%' ESCAPE '\\' and dr_re.RESTYPE = 'apppms' 

2. 잘못된 출력
printf(reply->element[1]->str); 출력시에
결과)
like 'OTRMES 0x0p+0nd monname in ('ME', 'MR')
re.RESNAME not like 'dr\\_ 6.951058E-310SCAPE '\\' and re.RESTYPE = 'apppms' 

1. 올바른 출력과 
2. 잘못된 출력으로 나타난 결과입니다. 해석과 리포트용으로 작성해주세요.

답변)

printf() 사용 방식에 따른 Redis Reply 문자열 변형 현상 분석 보고서

1. 현상 개요

Redis redisReply 구조체의 reply->element[1]->str에 저장된 SQL 문자열을 출력하는 과정에서 출력 방식에 따라 원본 문자열이 정상적으로 출력되거나 비정상적으로 변형되는 현상이 확인되었다.

동일한 reply->element[1]->str에 대하여 다음 두 가지 방식으로 출력하였다.

정상적인 출력

 
printf("%s", reply->element[1]->str);
 

출력 결과:

instancename like 'OTRMES%'
and monname in ('ME', 'MR')
)d ON
dr_re.RESNAME like 'dr\\_%' ESCAPE '\\'
and dr_re.RESTYPE = 'apppms'
 

비정상적인 출력

 
printf(reply->element[1]->str);
 

출력 결과:

like 'OTRMES 0x0p+0nd monname in ('ME', 'MR')
re.RESNAME not like 'dr\\_ 6.951058E-310SCAPE '\\'
and re.RESTYPE = 'apppms'
 

즉, Redis에서 전달받은 SQL 자체가 변경된 것이 아니라 printf() 호출 방식에 의해 문자열이 잘못 해석되고 있는 것으로 판단된다.


2. 원인 분석

문제의 핵심은 다음 코드이다.

 
printf(reply->element[1]->str);
 

C 언어의 printf()에서 첫 번째 인자는 단순한 문자열이 아니라 format string으로 취급된다.

즉,

 
printf("%s", reply->element[1]->str);
 

 
printf(reply->element[1]->str);
 

는 완전히 다른 의미를 가진다.


3. 정상적인 코드의 동작

다음 코드는:

 
printf("%s", reply->element[1]->str);
 

첫 번째 인자인:

%s
 

를 format string으로 사용하고,

두 번째 인자인:

 
reply->element[1]->str
 

을 %s에 대응하는 문자열 데이터로 전달한다.

따라서 SQL 내부에 %가 존재하더라도 문제가 없다.

예를 들어 Redis 데이터가:

 
instancename like 'OTRMES%'
 

라면 %는 reply->element[1]->str의 일반적인 문자열 데이터일 뿐이다.

따라서 정상적으로:

instancename like 'OTRMES%'
 

가 출력된다.


4. 비정상적인 코드의 동작

반면 다음 코드는:

 
printf(reply->element[1]->str);
 

reply->element[1]->str 자체를 format string으로 사용한다.

문제는 SQL 안에 %가 포함되어 있다는 것이다.

예를 들어 원본 SQL:

 
instancename like 'OTRMES%'
 

이 printf()에 직접 전달되면:

OTRMES%
 

의 %를 printf()가 format specifier의 시작으로 해석한다.

또한 SQL에는 다음과 같이 %가 여러 곳에 존재한다.

 
instancename like 'OTRMES%'
 
 
dr_re.RESNAME like 'dr\\_%' ESCAPE '\\'
 
 
re.RESNAME not like 'dr\\_%' ESCAPE '\\'
 
 
a.groupname like '%ME'
 

따라서 SQL 문자열 전체가 printf()의 format string으로 해석되면서 의도하지 않은 format 처리가 발생한다.


5. 0x0p+0, 6.951058E-310 등이 나타나는 이유

이번 현상에서 특히 중요한 부분은 다음과 같은 값이다.

0x0p+0
 

그리고:

6.951058E-310
 

이 값들은 원본 Redis 데이터에 존재하는 값이 아니다.

printf()가 format string 안에서 %를 발견하면 그 뒤의 문자를 보고 어떤 타입의 인자를 가져올지 결정한다.

그런데 현재 코드:

 
printf(reply->element[1]->str);
 

에서는 printf()가 요구하는 추가 인자를 전달하지 않았다.

예를 들어 format string에 특정 부동소수점 형식이 해석되면 printf()는 해당 값이 인자로 전달되었다고 가정하고 호출 규약상의 레지스터/스택 영역에서 값을 읽게 된다.

그러나 실제로는 그러한 인자를 전달하지 않았기 때문에 정의되지 않은 동작(Undefined Behavior) 이 발생한다.

그 결과 우연히 메모리나 레지스터에 있던 값이 해석되어:

6.951058E-310
 

또는:

0x0p+0
 

같은 의미 없는 값으로 출력될 수 있다.

따라서 다음과 같은 변형:

OTRMES 0x0p+0nd
 

은 실제 Redis 문자열이:

OTRMES 0x0p+0nd
 

였다는 의미가 아니다.

printf()가 %를 잘못 해석한 결과이다.


6. 현재 확인된 데이터 변형

원본 데이터:

instancename like 'OTRMES%'
and monname in ('ME', 'MR')
 

정상 출력:

 
printf("%s", reply->element[1]->str);
 

결과:

instancename like 'OTRMES%'
and monname in ('ME', 'MR')
 

반면:

 
printf(reply->element[1]->str);
 

결과:

like 'OTRMES 0x0p+0nd monname in ('ME', 'MR')
 

즉 원본의:

OTRMES%
 

부분이 변형되었다.

또 다른 원본:

re.RESNAME not like 'dr\\_%' ESCAPE '\\'
 

정상 출력:

re.RESNAME not like 'dr\\_%' ESCAPE '\\'
 

비정상 출력:

re.RESNAME not like 'dr\\_ 6.951058E-310SCAPE '\\'
 

여기에서도 원본의 %가 존재하는 부분에서 문자열이 변형되었다.


7. 기존 sprintf() 코드와의 관계

앞서 사용한 코드:

 
sprintf(tmpText, reply->element[1]->str);
 

역시 동일한 문제가 발생한다.

printf()와 sprintf() 모두 첫 번째 인자를 format string으로 취급하기 때문이다.

따라서:

 
printf(reply->element[1]->str);
 

뿐만 아니라:

 
sprintf(tmpText, reply->element[1]->str);
 

도 잘못된 사용이다.

잘못된 코드

 
printf(reply->element[1]->str);
 
sprintf(tmpText, reply->element[1]->str);
 

정상적인 코드

 
printf("%s", reply->element[1]->str);
 
sprintf(tmpText, "%s", reply->element[1]->str);
 

또는 보다 안전하게:

 
snprintf(tmpText, sizeof(tmpText), "%s", reply->element[1]->str);
 

8. Redis 데이터 자체의 문제인지 여부

현재 현상만 놓고 보면 Redis 또는 hiredis가 SQL을 변형한 것으로 볼 근거는 없다.

동일한 reply->element[1]->str에 대해:

 
printf("%s", reply->element[1]->str);
 

를 사용했을 때:

OTRMES%
dr\\_%
 

등이 정상적으로 출력되고 있다.

이는 Redis에서 전달받은 문자열이 정상적으로 존재하고 있음을 보여준다.

따라서 문제 발생 위치는 다음과 같이 판단할 수 있다.

Redis
hiredis
redisReply
reply->element[1]->str
정상적인 문자열
printf(reply->element[1]->str)
❌ format string으로 잘못 해석
문자열 변형 및 Undefined Behavior
 

9. reply->element[1]->len이 달라지는 현상과의 관계

앞서 확인한:

reply->element[1]->len = 4412
strlen(tmpText) = 4374
 

와 같은 현상 역시 이 문제와 연관성이 높다.

Redis의:

 
reply->element[1]->len
 

은 Redis에서 전달받은 문자열의 실제 길이를 나타낸다.

반면:

 
strlen(tmpText)
 

는 sprintf()로 변형된 문자열의 길이를 측정한다.

따라서:

 
sprintf(tmpText, reply->element[1]->str);
 

로 문자열이 변형되면:

원본 Redis 문자열
│ len = 4412
printf/sprintf format 해석
│ 문자열 일부 변형
tmpText
│ strlen = 4374
4412 != 4374
 

가 될 수 있다.

따라서 LEN[4374]와 같은 결과가 나타난 것은 Redis 데이터의 길이가 잘못된 것이 아니라 format string 오용으로 인해 복사/출력 과정에서 문자열이 변형되었기 때문으로 판단된다.


10. 최종 판단

원인

 
printf(reply->element[1]->str);
 

 
sprintf(tmpText, reply->element[1]->str);
 

와 같이 외부에서 전달받은 문자열을 format string으로 직접 사용한 것이 원인이다.

특히 SQL에는:

%
 

문자가 다수 포함되어 있으므로 문제가 쉽게 발생한다.

증상

  • %가 포함된 SQL 부분이 변형됨
  • 0x0p+0 등의 비정상적인 값이 출력됨
  • 6.951058E-310 등의 부동소수점 형태의 의미 없는 값이 출력됨
  • 원본 SQL과 출력 SQL의 길이가 달라짐
  • reply->element[1]->len과 strlen() 결과가 달라짐
  • 결과적으로 기존 코드에서 reply(FAIL)-LEN이 발생함

정상적인 처리 방법

 
printf("%s", reply->element[1]->str);
 

문자열 복사라면:

 
snprintf(tmpText,
sizeof(tmpText),
"%s",
reply->element[1]->str);
 

또는 Redis reply의 정확한 len을 유지하려면:

 
memcpy(tmpText,
reply->element[1]->str,
reply->element[1]->len);
 
tmpText[reply->element[1]->len] = '\0';
 

결론

Redis QCS 응답 또는 SQL 데이터가 비정상적으로 변경된 것이 아니라, printf(reply->element[1]->str)와 sprintf(tmpText, reply->element[1]->str)에서 발생한 Format String 취약성/오용이 직접적인 원인으로 판단된다.

특히 SQL의 LIKE 'OTRMES%', LIKE 'dr\\_%', LIKE '%ME' 등의 % 문자가 printf()의 format specifier로 해석되면서 정의되지 않은 동작이 발생했고, 그 결과 0x0p+0, 6.951058E-310 등의 비정상적인 값이 삽입된 것처럼 출력된 것이다.

따라서 Redis 서버, hiredis, redisReply, SQL 생성부를 우선 의심할 문제가 아니라 출력 및 문자열 복사 과정의 printf/sprintf 사용법을 수정하는 것이 1차적인 해결책이다.

질문)
위의 SQL문장을 c#클라이언트에서 Push로 Redis로 보내는부분입니다. 위의 SQL 일부에 re.RESNAME not like 'dr\_%' ESCAPE '\' and re.RESTYPE = 'apppms' 는 redis에서 POP해서 c언어에서 파싱할수 있도록 하기 위해서 특수문자를 넣는것이라고 볼수 있나요? 아니면 원래 저렇게 사용하는것인가요?

답변)

네. re.RESNAME not like 'dr\_%' ESCAPE '\'는 Redis로 보내기 위해 특수문자를 넣은 것이 아닙니다.
이것은 Oracle SQL의 LIKE 문법에서 _를 문자 그대로 검색하기 위해 원래 사용하는 SQL 표현입니다.

핵심은 LIKE에서 _가 특별한 의미를 갖는다는 점입니다.

1. Oracle LIKE에서 _의 의미

Oracle에서:

 
LIKE 'dr_%'
 

라고 하면 _는 임의의 문자 1개를 의미합니다.

즉,

 
dr_abc
drXabc
dr1abc
dr-abc
 

등이 모두 조건에 걸릴 수 있습니다.


2. 그런데 dr_라는 문자열 자체를 의미하고 싶다면?

현재 SQL은:

 
re.RESNAME not like 'dr\_%' ESCAPE '\'
 

입니다.

여기서:

 
ESCAPE '\'
 

백슬래시 \를 LIKE의 escape 문자로 사용하겠다는 뜻입니다.

따라서:

 
dr\_%
 

에서

\_
 

는 "_를 LIKE의 특수문자로 해석하지 말고 실제 _ 문자로 취급하라"는 의미입니다.

즉 이 조건은:

RESNAME이 "dr_"로 시작하지 않는 것
 

을 의미합니다.

예를 들어:

dr_ABC
dr_MES_001
dr_TEST
 

는 제외되고,

abc_ABC
drXABC
MES_001
 

등은 제외되지 않습니다.


3. ESCAPE '\'가 없으면?

예를 들어:

 
re.RESNAME not like 'dr_%'
 

이면 _가 와일드카드이므로:

drA123
drX001
dr12345
 

같은 것도 dr_... 패턴에 매칭될 수 있습니다.

반면:

 
re.RESNAME not like 'dr\_%' ESCAPE '\'
 

이면 정확히 dr_로 시작하는 문자열을 대상으로 합니다.


4. Redis → C 파싱을 위해 넣은 문자가 아닙니다

따라서 현재 구조가

C# 프로그램
   ↓
SQL 문자열 생성
   ↓
Redis PUSH
   ↓
C 프로그램 POP
   ↓
Oracle에서 SQL 실행
 

이라고 하더라도,

 
dr\_%
 

의 \는 Redis 전송용 escape가 아닙니다.

C 프로그램에서 Redis에서 POP한 후 SQL 문자열을 파싱한다고 해서 특별히 넣은 것도 아닙니다.

Oracle SQL 자체에서 필요한 문자입니다.


5. 특히 현재 SQL에서는 두 군데가 있습니다

질문하신 부분:

 
re.RESNAME not like 'dr\_%' ESCAPE '\'
 

그리고 아래에도:

 
dr_re.RESNAME like 'dr\_%' ESCAPE '\'
 

가 있습니다.

둘 다 같은 이유입니다.

첫 번째:

 
re.RESNAME not like 'dr\_%' ESCAPE '\'
 

→ dr_로 시작하는 RESNAME 제외

두 번째:

 
dr_re.RESNAME like 'dr\_%' ESCAPE '\'
 

→ dr_로 시작하는 RESNAME 선택

즉 하나는 일반 apppms 리소스, 다른 하나는 dr_ 리소스를 구분하기 위한 것으로 보입니다.

결론

 
'dr\_%' ESCAPE '\'
 

의 \는 Redis 때문에 들어간 것이 아니라 Oracle LIKE에서 _를 일반 문자로 사용하기 위한 SQL 문법입니다.

따라서 C# → Redis → C 구조가 아니더라도 원래 SQL에 그대로 있어야 하는 표현입니다.

참고로 C#에서 이 SQL을 @"..." verbatim string으로 만들었기 때문에 현재처럼:

 
@"... 'dr\_%' ESCAPE '\' ..."
 

라고 작성할 수 있습니다. 일반 C# 문자열("...")이었다면 백슬래시 처리 방식이 달라집니다.

질문)

{"param":{"stmt":" with tbl_now_clock as ( select /*+ APM UI(UAPAMSQ0600-1) */ imf_nowclock-2 now_clock from dual ) SELECT title, MAX(bStatus), NVL(MAX(bitemid),0), NVL(MAX(mrDRSNDCNT),0), NVL(MAX(meDRSNDCNT),0), NVL(MAX(dstatus),'N'), NVL(MAX(drgstatus),'EMPTY'), MAX(RESADDR3), RESNAME from ( select re.RESADDR2 title,a.RESADDR3 RESADDR3, NVL(c.STATUS,'DOWN') bStatus, c.itemid bitemid, MONNAME, decode() and re.RESNAME not like 'dr\\_%' ESCAPE '\\' and re.RESTYPE = 'apppms' and re.RESADDR3 like '%me%' LEFT JOIN ( select itemid, case status when 1 then 'ACTIVE' when 2 then 'STANDBY' when 3 then 'WARM' else 'DOWN' end status RESADDR3 like '%me%' LEFT JOIN ( select itemid, case status when 1 then 'ACTIVE' when 2 then 'STANDBY' when 3 then 'WARM' else 'DOWN' end status from imt_mams_instance where clock= (select now_clock from tbl_now_clock) ) dr_c on dr_b.ITEMID = dr_c.ITEMID where dr_a.groupname like '%ME' and dr_a.itemkey = 'mams.me' and dr_a.resaddr3='STK' and dr_a.RESADDR1 = 'MES' and dr_b.itemkey='mams.me' ) DRG on substr(DRG.RESNAME,4) = a.RESNAME where a.groupname like '%ME' and a.itemkey = 'mams.me' and a.resaddr3='STK' and a.RESADDR1 = 'MES' and b.itemkey='mams.me') group by title,RESNAME order by RESNAME ","colcnt":"7"},"proto":"r","sender":"1127001637917220102/5940","id":"QUERY.0000658546.001","ts":"1786401234","method":"dynbigsel","screen":"UAPAMSQ0600-1"}

위는 LENGTH:[4410] 입니다.

위를 
byte[] jsonByte = sendQueue.Dequeue();
resultCode = _sendRedis.RPush("QCS", jsonByte); 

보낸다면 reply = redisCommand(stRedis, "BLPOP QCS") 에 대한 reply 결과는

답변)

네. 중요한 점은 Redis BLPOP의 reply에는 JSON 내용이 그대로 reply->element[1]->str에 들어온다는 것입니다.

보내는 쪽이:

 
byte[] jsonByte = sendQueue.Dequeue();
resultCode = _sendRedis.RPush("QCS", jsonByte);
 

이고 jsonByte의 길이가 4410 byte라면, Redis에 다음과 같은 형태로 저장됩니다.

QCS
 └── [4410 byte의 JSON 데이터]
 

서버에서:

 
reply = redisCommand(stRedis, "BLPOP QCS");
 

를 실행하면 reply는 대략 다음 구조가 됩니다.

reply
│
├── type      = REDIS_REPLY_ARRAY
├── elements  = 2
│
└── element
    │
    ├── [0]
    │    ├── type = REDIS_REPLY_STRING
    │    ├── len  = 3
    │    └── str  = "QCS"
    │
    └── [1]
         ├── type = REDIS_REPLY_STRING
         ├── len  = 4410
         └── str  = "{\"param\":{\"stmt\":\" with tbl_now_clock ... "
 

즉:

 
reply->element[0]->str
 

QCS
 

이고,

 
reply->element[1]->str
 

실제 JSON 데이터입니다.


실제로 보면

서버에서 다음과 같이 출력하면 됩니다.

 
redisReply *reply;

reply = redisCommand(stRedis, "BLPOP QCS");

if (reply != NULL)
{
    printf("reply->type     = %d\n", reply->type);
    printf("reply->elements = %zu\n", reply->elements);

    if (reply->type == REDIS_REPLY_ARRAY &&
        reply->elements == 2)
    {
        printf("key len   = %zu\n",
               reply->element[0]->len);

        printf("value len = %zu\n",
               reply->element[1]->len);

        printf("key   = %s\n",
               reply->element[0]->str);

        printf("value = %s\n",
               reply->element[1]->str);
    }
}
 

예상 결과는:

reply->type     = 2
reply->elements = 2

key len   = 3
value len = 4410

key   = QCS
value = {"param":{"stmt":" with tbl_now_clock as ...
 

단, REDIS_REPLY_ARRAY의 숫자값은 hiredis 버전에 따라 헤더의 enum 정의를 기준으로 보는 것이 좋으므로 숫자 자체보다는 REDIS_REPLY_ARRAY를 사용하세요.


그런데 여기서 중요한 부분

현재 데이터가:

LENGTH:[4410]
 

이라고 하셨는데, C#의 byte[] 4410 byte와 C 서버에서 reply->element[1]->len 4410이 항상 동일하다고 단정하면 안 됩니다.

왜냐하면 C#에서:

 
byte[] jsonByte
 

를 Redis에 넣었을 때는 UTF-8 byte 배열 자체가 들어갑니다.

따라서 서버에서는:

 
reply->element[1]->len
 

Redis에 실제 저장된 byte 길이입니다.

JSON 안에 한글이 있다면:

문자 개수
≠
byte 개수
 

가 될 수 있습니다.

하지만 C#에서 jsonByte.Length == 4410이고 Redis에 그대로 RPush했다면 일반적으로:

 
reply->element[1]->len == 4410
 

이 됩니다.


그리고 reply->element[1]->str의 끝 처리

이 부분도 중요합니다.

hiredis의 STRING reply는:

 
reply->element[1]->str
 

에 데이터가 있고,

 
reply->element[1]->len
 

에 정확한 길이가 있습니다.

따라서 단순히:

 
printf("%s", reply->element[1]->str);
 

해도 일반적인 JSON 문자열이라면 정상적으로 출력됩니다.

하지만 바이너리 안전하게 처리하려면 len을 기준으로 해야 합니다.

예:

 
printf("%.*s",
       (int)reply->element[1]->len,
       reply->element[1]->str);
 

또는 기존 C 프로그램에서 별도의 버퍼로 복사한다면:

 
size_t len = reply->element[1]->len;

memcpy(buffer,
       reply->element[1]->str,
       len);

buffer[len] = '\0';
 

전체 흐름을 정리하면

현재 구조는 사실상 다음과 같습니다.

[C# Client]

JSON
  │
  ▼
byte[] jsonByte
  │
  │ Length = 4410
  ▼
RPush("QCS", jsonByte)
  │
  ▼
┌──────────────────────────────┐
│ Redis QCS List               │
│                              │
│ [4410 byte JSON]             │
└──────────────────────────────┘
  │
  │ BLPOP QCS
  ▼
[C Server]

redisReply *
  │
  ├── type = REDIS_REPLY_ARRAY
  │
  ├── element[0]
  │      ├── len = 3
  │      └── str = "QCS"
  │
  └── element[1]
         ├── len = 4410
         └── str = JSON
                    │
                    ├── param
                    │    ├── stmt
                    │    └── ...
                    ├── proto
                    ├── sender
                    ├── id
                    ├── ts
                    ├── method
                    └── screen
 

따라서 현재 질문의 JSON은 reply 전체가 4410 byte가 되는 것이 아니라, reply->element[1]이 4410 byte의 JSON 데이터를 갖는 것으로 이해하시면 정확합니다.

그리고 서버 코드에서 실제 처리 대상은 보통:

 
char *json = reply->element[1]->str;
size_t jsonLen = reply->element[1]->len;
 

입니다.

redisCommand(stRedis, "BLPOP %s %d", cpKeyNm, iTimeout)가 반환하는 reply는 hiredis의 redisReply * 구조체입니다.

 
redisReply *reply;
 

BLPOP의 반환 형태를 이해하면 구조가 명확합니다.

1. BLPOP 성공 시

예를 들어 Redis에 다음 데이터가 있다고 하면:

LPUSH myqueue "hello"
 

C 코드:

 
reply = redisCommand(stRedis, "BLPOP %s %d", "myqueue", 10);
 

Redis의 실제 응답은 논리적으로:

["myqueue", "hello"]
 

이고, redisReply는 대략 다음과 같이 구성됩니다.

 
reply
 ├── type     = REDIS_REPLY_ARRAY
 ├── integer  = 0
 ├── len      = 2
 ├── str      = NULL
 ├── elements
 │    ├── [0]
 │    │    ├── type = REDIS_REPLY_STRING
 │    │    ├── len  = 7
 │    │    └── str  = "myqueue"
 │    │
 │    └── [1]
 │         ├── type = REDIS_REPLY_STRING
 │         ├── len  = 5
 │         └── str  = "hello"
 

즉, BLPOP 성공 결과는 2개의 element를 가진 ARRAY입니다.


2. 실제 C 구조체

hiredis의 redisReply는 기본적으로 다음과 같은 형태입니다.

 
typedef struct redisReply {
    int type;
    long long integer;
    size_t len;
    char *str;
    size_t elements;
    struct redisReply **element;
} redisReply;
 

중요한 부분은:

 
reply->type
reply->elements
reply->element[]
 

입니다.

따라서 BLPOP은 다음처럼 접근합니다.

 
printf("type     = %d\n", reply->type);
printf("elements = %zu\n", reply->elements);

printf("key      = %s\n", reply->element[0]->str);
printf("value    = %s\n", reply->element[1]->str);
 

결과:

type     = REDIS_REPLY_ARRAY
elements = 2
key      = myqueue
value    = hello
 

3. 중요한 것은 BLPOP timeout

BLPOP은 timeout이 발생하면 NULL이 아니라 ARRAY 타입의 NULL 응답이 올 수 있습니다.

예:

 
reply = redisCommand(stRedis, "BLPOP %s %d", cpKeyNm, iTimeout);

if (reply == NULL) {
    // Redis 연결 오류 등
}
else if (reply->type == REDIS_REPLY_NIL) {
    // timeout
}
else if (reply->type == REDIS_REPLY_ARRAY) {
    // 정상적으로 데이터 수신
}
 

정상:

reply->type = REDIS_REPLY_ARRAY
reply->elements = 2
 

Timeout:

reply->type = REDIS_REPLY_NIL
 

4. 실무에서는 이렇게 확인하는 것이 좋습니다

 
redisReply *reply;

reply = redisCommand(
    stRedis,
    "BLPOP %s %d",
    cpKeyNm,
    iTimeout
);

if (reply == NULL)
{
    printf("Redis command error\n");
}
else if (reply->type == REDIS_REPLY_NIL)
{
    printf("BLPOP timeout\n");
}
else if (reply->type == REDIS_REPLY_ARRAY)
{
    if (reply->elements >= 2)
    {
        printf("KEY   : %s\n", reply->element[0]->str);
        printf("VALUE : %s\n", reply->element[1]->str);
    }
}
else
{
    printf("Unexpected reply type: %d\n", reply->type);
}

freeReplyObject(reply);
 

5. 특히 현재 코드에서 확인해야 할 부분

현재 코드가

 
reply = redisCommand(stRedis, "BLPOP %s %d", cpKeyNm, iTimeout);
 

라면 reply->str을 바로 보면 안 됩니다.

BLPOP은 ARRAY이므로 실제 데이터는:

 
reply->element[0]->str   // Redis Key
reply->element[1]->str   // Pop된 Value
 

입니다.

예를 들어:

cpKeyNm = "QCS"

Redis:
LPUSH QCS "ABC|123|HELLO"
 

라면:

 
reply->element[0]->str
 

→ "QCS"

 
reply->element[1]->str
 

→ "ABC|123|HELLO"

입니다.

그리고 redisReply의 메모리 해제는 반드시

 
freeReplyObject(reply);
 

로 처리해야 합니다.

라디오 선곡표 기반 유튜브 음원(MP3) 자동 추출 시스템(2)

1. 선곡표 입력 into Youtube_playList

1.1 중복되어진 제목이 있는지 검사

SELECT `query`, COUNT(*) AS cnt FROM radio_playlist GROUP BY `query` HAVING COUNT(*) > 1 ORDER BY cnt DESC, `query`;

1.2 중복되어진 데이타를 1개만 남기고 삭제
- 단 삭제전에 중복데이타가 많으면, 시간이 오래걸리므로, 시간단축을 위해서 INDEX를 생성후에 삭제한다.

DELETE t1 FROM radio_playlist t1 JOIN radio_playlist t2 ON t1.`query` = t2.`query` AND t1.id > t2.id;

JOIN 방식은 인덱스가 없거나 데이터가 많은 경우 매우 느릴 수 있습니다.
먼저 인덱스가 있는지 확인하세요.

SHOW INDEX FROM radio_playlist;
 

query 컬럼에 인덱스가 없다면 가장 먼저 추가하는 것이 좋습니다.

CREATE INDEX idx_radio_playlist_query_id
ON radio_playlist(`query`, id);

1.3 실행결과

mysql> select count(1) from radio_playlist;
+----------+
| count(1) |
+----------+
|    80212 |
+----------+
1 row in set (0.04 sec)

mysql> CREATE INDEX idx_radio_playlist_query_id
    -> ON radio_playlist(`query`, id);
Query OK, 0 rows affected (0.75 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> DELETE t1
    -> FROM radio_playlist t1
    -> JOIN radio_playlist t2
    ->   ON t1.`query` = t2.`query`
    ->  AND t1.id > t2.id;
Query OK, 67107 rows affected (9.51 sec)

mysql> SELECT `query`, COUNT(*) AS cnt FROM radio_playlist GROUP BY `query` HAVING COUNT(*) > 1 ORDER BY cnt DESC, `query`;
Empty set (0.02 sec)

mysql> select count(1) from radio_playlist;
+----------+
| count(1) |
+----------+
|    13105 |
+----------+
1 row in set (0.00 sec)

mysql>

80212 건이 13105 으로 줄어듦을 확인할수 있다.
라디오 프로그램 특성상, 중복선곡이 많을수 있다.



2. Youtube Link 생성 frm 선곡표 기반(선곡표 중복생성 방지 포함)

SELECT CONCAT('music - ', r.query) AS query 
FROM youtube_18_00.radio_playlist r
WHERE NOT EXISTS (
    SELECT 1 
    FROM youtube_18_00.youtube_video v 
    WHERE v.query = CONCAT('music - ', r.query)
)
ORDER BY r.id ASC 
LIMIT 200;

3. Youtube mp3 file 생성 frm Youtube Link( Youtube Link 중복생성 방지 포함 )

SELECT *
FROM youtube.youtube_video v
WHERE NOT EXISTS
(
    SELECT 1
    FROM youtube.youtube_download_queue q
    WHERE q.url = v.url
      AND q.download_status = 'SUCCESS'
)
ORDER BY v.view_count DESC
LIMIT 600;

4. 테이블정리

mysql> show tables;
+-------------------------+
| Tables_in_youtube_18_00 |
+-------------------------+
| radio_playlist          |
| youtube_download_queue  |
| youtube_video           |
+-------------------------+
3 rows in set (0.01 sec)

mysql> desc radio_playlist
    -> ;
+-------------+--------------+------+-----+-------------------+-------------------+
| Field       | Type         | Null | Key | Default           | Extra             |
+-------------+--------------+------+-----+-------------------+-------------------+
| id          | bigint       | NO   | PRI | NULL              | auto_increment    |
| play_date   | date         | NO   | MUL | NULL              |                   |
| play_time   | time         | NO   |     | NULL              |                   |
| part_no     | int          | NO   |     | NULL              |                   |
| query       | varchar(500) | NO   |     | NULL              |                   |
| title       | varchar(500) | YES  |     | NULL              |                   |
| artist_name | varchar(255) | YES  |     | NULL              |                   |
| created_at  | datetime     | YES  |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED |
+-------------+--------------+------+-----+-------------------+-------------------+
8 rows in set (0.01 sec)

mysql> desc youtube_video;
+--------------+--------------+------+-----+-------------------+-------------------+
| Field        | Type         | Null | Key | Default           | Extra             |
+--------------+--------------+------+-----+-------------------+-------------------+
| id           | bigint       | NO   | PRI | NULL              | auto_increment    |
| url          | varchar(500) | NO   | UNI | NULL              |                   |
| summaryurl   | varchar(500) | NO   |     | NULL              |                   |
| title        | varchar(500) | NO   |     | NULL              |                   |
| query        | varchar(500) | YES  |     | NULL              |                   |
| channel_name | varchar(255) | YES  |     | NULL              |                   |
| duration     | char(8)      | YES  |     | NULL              |                   |
| view_count   | bigint       | YES  |     | NULL              |                   |
| created_at   | datetime     | YES  |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED |
+--------------+--------------+------+-----+-------------------+-------------------+
9 rows in set (0.00 sec)

mysql> desc youtube_download_queue;
+-----------------+---------------+------+-----+-------------------+-----------------------------------------------+
| Field           | Type          | Null | Key | Default           | Extra                                         |
+-----------------+---------------+------+-----+-------------------+-----------------------------------------------+
| id              | bigint        | NO   | PRI | NULL              | auto_increment                                |
| url             | varchar(500)  | NO   | UNI | NULL              |                                               |
| download_status | varchar(20)   | NO   |     | WAIT              |                                               |
| error_message   | varchar(1000) | YES  |     | NULL              |                                               |
| created_at      | datetime      | YES  |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED                             |
| updated_at      | datetime      | YES  |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
+-----------------+---------------+------+-----+-------------------+-----------------------------------------------+
6 rows in set (0.00 sec)

mysql>

 

1. 직관적이고 설명적인 제목 (어떤 프로젝트인지 한눈에 알 수 있는 제목)

  • 라디오 선곡표 기반 유튜브 음원(MP3) 자동 추출 시스템
  • MySQL 기반 유튜브 음원 자동 수집 및 다운로드 파이프라인
  • 라디오 플레이리스트 유튜브 MP3 아카이빙 봇
  • 음악 검색부터 MP3 다운로드까지: 선곡표 자동화 시스템

2. 서비스/앱 느낌의 세련된 제목 (토이 프로젝트나 앱 이름으로 적합)

  • Radio2MP3: 라디오 선곡 기반 유튜브 음원 수집기
  • TubeCaster: 선곡표-유튜브 음원 자동 매칭 및 추출기
  • AutoMusicMiner: DB 기반 플레이리스트 자동 다운로더
  • SyncRadio: 라디오 방송 선곡표 MP3 자동 동기화 프로젝트

3. 포트폴리오/이력서용 제목 (기술적 역량을 강조)

  • 관계형 DB(MySQL)를 활용한 유튜브 음원 자동 수집 파이프라인 구축
  • 라디오 선곡 데이터 기반 유튜브 동영상 배치(Batch) 다운로드 및 MP3 변환 시스템
  • 비동기 다운로드 큐(Queue)를 활용한 유튜브 미디어 파일 수집 자동화

mysql>                                                         
mysql> show tables;                                            
+------------------------+                                     
| Tables_in_youtube      |                                     
+------------------------+                                     
| radio_playlist   - 김용신의 라디오 프로그램 07:00 날짜별 선곡리스트
| youtube_download_queue - mp3 변환여부 리스트
| youtube_video  -  김용신의 라디오 프로그램 선곡리스트의 유트브주소
+------------------------+                                     
3 rows in set (0.03 sec)       

                                
                                                               
mysql> select * from youtube_video limit 10;                   
+------+---------------------------------------------+---------
| id   | url                                         | summaryu
+------+---------------------------------------------+---------
| 4080 | https://www.youtube.com/watch?v=m2tn845eY20 | https://
| 4081 | https://www.youtube.com/watch?v=4Uw2NQb1j4Y | https://
| 4082 | https://www.youtube.com/watch?v=m6uw4QPEX9I | https://
+------+---------------------------------------------+---------
10 rows in set (0.03 sec)                                      
                                                   

            
mysql> select * from radio_playlist limit 10;                  
+------+------------+-----------+---------+--------------------
| id   | play_date  | play_time | part_no | query              
+------+------------+-----------+---------+--------------------
| 3963 | 2010-05-29 | 07:00:00  |       1 | music - Hands Up - 
| 3964 | 2010-05-29 | 07:00:00  |       1 | music - And I Love 
| 3965 | 2010-05-29 | 07:00:00  |       1 | music - Changing Pa
+------+------------+-----------+---------+--------------------
10 rows in set (0.02 sec)                                      
                                                               
mysql> select * from youtube_download_queue limit 10;          
+----+---------------------------------------------+-----------
| id | url                                         | download_s
+----+---------------------------------------------+-----------
|  1 | https://www.youtube.com/watch?v=pRpeEdMmmQ0 | SUCCESS   
|  2 | https://www.youtube.com/watch?v=OPf0YbXqDm0 | SUCCESS   
|  3 | https://www.youtube.com/watch?v=kJQP7kiw5Fk | SUCCESS   
+----+---------------------------------------------+-----------
10 rows in set (0.02 sec)                                      
                                                               
mysql>                                                         

유트부주소를 입력하면, 조회수를 가져오는 방법으로 조회수가 높은 곡을 우선으로 mp3 파일을 만든다.                                             

==========================================
[전체 목록 일괄 검색 시작]
==========================================
링크: https://www.youtube.com/watch?v=zHd4wBLNljk제목: Che sar? 채널: Jos? Feliciano - Topic 길이: 00:03:32 조회수: 407,504회 https://www.youtube.com/watch?v=zHd4wBLNljk
링크: https://www.youtube.com/watch?v=E07s5ZYygMg제목: Harry Styles - Watermelon Sugar (Official Video) 채널: HarryStylesVEVO 길이: 00:03:09 조회수: 432,237,003회 https://www.youtube.com/watch?v=E07s5ZYygMg
링크: https://www.youtube.com/watch?v=VV1XWJN3nJo제목: Natalie Imbruglia - Torn (Official Video) 채널: natalieimbrugliaVEVO 길이: 00:04:03 조회수: 368,931,920회 https://www.youtube.com/watch?v=VV1XWJN3nJo
링크: https://www.youtube.com/watch?v=3f56qh5PmUA제목: Lee Oskar - Before The Rain 채널: OTA PEREIRA 길이: 00:08:35 조회수: 2,334,438회 https://www.youtube.com/watch?v=3f56qh5PmUA
링크: https://www.youtube.com/watch?v=W0_x6CnbIsc제목: Si T? Me Amas 채널: Il Divo - Topic 길이: 00:04:08 조회수: 2,025,179회 https://www.youtube.com/watch?v=W0_x6CnbIsc
링크: https://www.youtube.com/watch?v=c6rP-YP4c5I제목: Shakira - Try Everything (Official Video) 채널: shakiraVEVO 길이: 00:03:22 조회수: 807,946,582회 https://www.youtube.com/watch?v=c6rP-YP4c5I
링크: https://www.youtube.com/watch?v=VcaWuY4YcH8제목: Come Rain or Come Shine 채널: Eric Clapton - Topic 길이: 00:04:10 조회수: 269,005회 https://www.youtube.com/watch?v=VcaWuY4YcH8
링크: https://www.youtube.com/watch?v=r6fLLxwD5E0제목: The Dramatics - In The Rain LIVE SD (with lyrics) 1972 채널: Shane Mercury 길이: 00:05:17 조회수: 219,034회 https://www.youtube.com/watch?v=r6fLLxwD5E0
링크: https://www.youtube.com/watch?v=OADhKjNz8mI제목: Long Long Time (Remastered) 채널: Linda Ronstadt - Topic 길이: 00:04:22 조회수: 4,797,387회 https://www.youtube.com/watch?v=OADhKjNz8mI
링크: https://www.youtube.com/watch?v=dXVLAWF1OB8제목: Ricky Martin Ft. Paul Anka - Diana 채널: El Rey Del Pop Latino Ricky Martin 길이: 00:03:43 조회수: 461,456회 https://www.youtube.com/watch?v=dXVLAWF1OB8
링크: https://www.youtube.com/watch?v=tZJ_czeCh6U제목: A Time For Us 채널: Donny & Marie Osmond - Topic 길이: 00:03:50 조회수: 283,260회 https://www.youtube.com/watch?v=tZJ_czeCh6U
링크: https://www.youtube.com/watch?v=x11NA63gLDM제목: Eric Clapton - Change The World 채널: Clark200666 길이: 00:03:56 조회수: 31,649,436회 https://www.youtube.com/watch?v=x11NA63gLDM
링크: https://www.youtube.com/watch?v=MUuNDb-nm5M제목: Michael Bolton - When a Man Loves a Woman (Official Video) 채널: MichaelBoltonVEVO 길이: 00:03:53 조회수: 281,073,240회 https://www.youtube.com/watch?v=MUuNDb-nm5M
링크: https://www.youtube.com/watch?v=N-udds8qmfk제목: Armik - Cartas De Amor (2020 Version) 채널: armikVEVO 길이: 00:04:42 조회수: 38,083회 https://www.youtube.com/watch?v=N-udds8qmfk
링크: https://www.youtube.com/watch?v=teJOKs6DL8E제목: VA PENSIERO   Zucchero & Sinead O Connor 채널: Mauricio batista de queiroz 길이: 00:03:55 조회수: 109,431회 https://www.youtube.com/watch?v=teJOKs6DL8E
링크: https://www.youtube.com/watch?v=WUOtCLOXgm8제목: Queen - I Want to Break Free (Official Lyric Video) 채널: Queen Official 길이: 00:04:23 조회수: 160,744,400회 https://www.youtube.com/watch?v=WUOtCLOXgm8
링크: https://www.youtube.com/watch?v=qt_OkgSOrkU제목: Andrea Bocelli, C?line Dion - The Prayer (Live at Central Park / 2011) 채널: AndreaBocelliVEVO 길이: 00:06:30 조회수: 245,144,793회 https://www.youtube.com/watch?v=qt_OkgSOrkU
링크: https://www.youtube.com/watch?v=pU-QExgydz0제목: Tony Bennett - The Good Life 채널: justinpow 길이: 00:03:10 조회수: 1,111,445회 https://www.youtube.com/watch?v=pU-QExgydz0
링크: https://www.youtube.com/watch?v=xFrGuyw1V8s제목: ABBA - Dancing Queen (Official Music Video) 채널: AbbaVEVO 길이: 00:03:53 조회수: 1,123,430,578회 https://www.youtube.com/watch?v=xFrGuyw1V8s
링크: https://www.youtube.com/watch?v=N-udds8qmfk제목: Armik - Cartas De Amor (2020 Version) 채널: armikVEVO 길이: 00:04:42 조회수: 38,083회 https://www.youtube.com/watch?v=N-udds8qmfk
==========================================
[전체 목록 일괄 검색 완료]
==========================================

                  

1. 준비음악 리스트
- 김용신의 그대와 여는 아침(~ 23.07.18)
- 허윤희의 꿈과 음악사이에(~ 23.07.17)
- 배미향의 저녁스케치(~ 23.07.17)
- 신지혜의 영화음악(~ 23.07.17)

 
xterm@DELLDESKTOP MINGW64 ~/Downloads/youtubeMusic/multipleExFrm
$ ls -lrt *.txt
-rw-r--r-- 1 xterm 197609  633886 Aug  1 06:55 radio_music_11_00.txt
-rw-r--r-- 1 xterm 197609  831576 Aug  1 07:25 radio_music_22_00.txt
-rw-r--r-- 1 xterm 197609 4202926 Aug  1 07:25 radio_music_18_00.txt
-rw-r--r-- 1 xterm 197609  251533 Aug  1 07:32 radio_music_07_10.txt
-rw-r--r-- 1 xterm 197609    4938 Aug  1 08:06 radio_music_12_00.txt

xterm@DELLDESKTOP MINGW64 ~/Downloads/youtubeMusic/multipleExFrm
$ wc radio_music_11_00.txt
 14593  84622 633886 radio_music_11_00.txt

xterm@DELLDESKTOP MINGW64 ~/Downloads/youtubeMusic/multipleExFrm
$ wc radio_music_22_00.txt
 40963  82323 831576 radio_music_22_00.txt

xterm@DELLDESKTOP MINGW64 ~/Downloads/youtubeMusic/multipleExFrm
$ wc radio_music_18_00.txt
 116720  576104 4202926 radio_music_18_00.txt

xterm@DELLDESKTOP MINGW64 ~/Downloads/youtubeMusic/multipleExFrm
$ wc radio_music_07_10.txt
  7503  36400 251533 radio_music_07_10.txt

xterm@DELLDESKTOP MINGW64 ~/Downloads/youtubeMusic/multipleExFrm
$

2. 선곡되어진 리스트를 radio_music_12_00.txt 에 저장한후에 리스트박스에 불러온후에 
API 키 없이 YouTube 검색 결과 페이지의 HTML을 직접 파싱하여 첫 번째 동영상 URL([https://www.youtube.com/watch?v=](https://www.youtube.com/watch?v=)...)을 가져오는 방법입니다.
현대 YouTube 페이지는 초기 HTML에 검색 결과 데이타가 JSON 형태로 포함되어 있습니다. 따라서 HttpClient로 검색 페이지를 요청한 뒤, 페이지 내부의 ytInitialData 또는 비디오 ID 패턴("/watch?v=")을 정규식(Regex)으로 파싱하면 첫 번째 동영상 주소를 추출할 수 있습니다.


3. 실제 mp3 파일로 변환하기
yt-dlp는 현재 가장 널리 쓰이고 성능이 뛰어난 오픈소스 음원/동영상 다운로드 도구입니다. 광고나 악성코드 우려가 있는 무료 웹사이트와 달리, CLI(명령 프롬프트) 기반이라 안전하고 원본 품질 그대로 추출할 수 있다는 큰 장점이 있습니다.

컴퓨터 사용이 익숙하시다면 아래 순서대로 쉽게 따라 하실 수 있습니다.

1. 준비 작업 (최초 1회 설치)
가장 깔끔하게 작동하도록 yt-dlp와 음원 변환 필수 도구인 ffmpeg를 함께 설치합니다.
Step 1. 작업 폴더 만들기
C: 드라이브에 yt라는 폴더를 하나 만듭니다. (C:\yt)
Step 2. yt-dlp 다운로드
yt-dlp 공식 GitHub 페이지에서 yt-dlp.exe 파일만 다운로드합니다.
다운로드한 yt-dlp.exe 파일을 만들어둔 C:\yt 폴더 안에 넣습니다.
Step 3. ffmpeg 다운로드 (MP3 변환용 필수)
ffmpeg 공식 또는 빌드 배포 사이트에서 ffmpeg.exe 파일을 다운로드합니다.
ffmpeg.exe 파일 역시 C:\yt 폴더 안에 함께 넣어줍니다.
결과: C:\yt 폴더 안에 yt-dlp.exe와 ffmpeg.exe 두 파일이 들어가 있으면 준비 완료입니다.

yt-dlp -x --audio-format mp3 --audio-quality 0 "유튜브_영상_주소"
--audio-format mp3: 추출된 오디오를 MP3 포맷으로 변환합니다.
--audio-quality 0: 변환 가능한 최고 품질(VBR 최고 음질, 약 240~320kbps 수준)로 추출합니다.
3. 재생목록(플레이리스트) 전체 한 번에 다운로드
앨범 전곡이나 재생목록 전체를 한 번에 MP3로 저장하고 싶다면, 재생목록 링크를 입력하면 됩니다.

yt-dlp -x --audio-format mp3 --audio-quality 0 -o "%(playlist_index)s - %(title)s.%(ext)s" "재생목록_주소"
-o "%(playlist_index)s - %(title)s.%(ext)s" 옵션을 넣으면 01 - 노래제목.mp3, 02 - 노래제목.mp3 형태로 순서대로 정렬되어 저장됩니다.

4. 꿀팁: 자주 쓰는 명령어를 배치 파일(.bat)로 자동화하기
매번 명령어를 입력하기 번거롭다면 메모장을 열어 아래 내용을 붙여넣고 download.bat 파일로 C:\yt 폴더에 저장해두면 편리합니다.


1시간정도만에, 1000곡 가까이 mp3파일로 만들수 있다.

실험)
환경)

장치 이름 DELLDESKTOP
프로세서 Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz(2.90 GHz)
설치된 RAM 16.0GB(15.7GB 사용 가능)
그래픽 카드 Intel(R) UHD Graphics 630 (128 MB)
저장소 1.36 TB 중 753 GB이(가) 사용됨
장치 ID 18847251-37C2-4764-A2C0-7797BBA67DF2
제품 ID 00326-10000-00000-AA099
시스템 종류 64비트 운영 체제, x64 기반 프로세서
펜 및 터치 이 디스플레이에 사용할 수 있는 펜 또는 터치식 입력이 없습니다.


특정파일에 대한 송신(송신버퍼 사이즈는 랜덤으로 5개정도로 설정해서 전송)
int size = rand.Next(MIN_RAND_NUMBER, MAX_RAND_NUMBER);
byte[] buffer = new byte[size * 1024];

특정파일에 대한 수신(송신버퍼는 byte[] buffer = new byte[1000 * 1024]로 설정)

송수신 통계분석)

코드)


private void SendToTail(int index, string data) {
  tailHandle = APMApiPublic.FindWindow(null, "DATADEBUGGING");
  if (tailHandle != IntPtr.Zero && data.Length > 0 &&
      APMApiPublic.IsWindow(tailHandle)) {
    try {
      byte[] dataByte = Encoding.UTF8.GetBytes(data);
      APMApiPublic.COPYDATASTRUCT copyData = new APMApiPublic.COPYDATASTRUCT();
      copyData.dwData = (IntPtr)0;
      copyData.cbData = dataByte.Length;
      copyData.lpData = Marshal.AllocHGlobal(dataByte.Length);
      Marshal.Copy(dataByte, 0, copyData.lpData, dataByte.Length);
      IntPtr sendData = Marshal.AllocHGlobal(Marshal.SizeOf(copyData));
      Marshal.StructureToPtr(copyData, sendData, true);
      IntPtr _result = APMApiPublic.SendMessage(
          tailHandle, APMApiPublic.WM_COPYDATA, (IntPtr)index, sendData);
      Marshal.FreeHGlobal(copyData.lpData);
      Marshal.FreeHGlobal(sendData);
    } catch (Exception exp) {
    }
  }
}

질문)
결국 위의 구조는 tcp 송수신처럼 SendMessage도 같은 구조라고 이해해도 될까요?
같은 구조란 수신에 문제가 생기면, 송신도 문제를 일으킨다는 구조를 말합니다.

답변)

네, 개념적으로는 매우 비슷하다고 이해하셔도 됩니다. 다만 이유는 서로 다릅니다.

TCP의 경우

TCP는 운영체제가 송신 버퍼와 수신 버퍼를 관리합니다.

Sender
   ↓
TCP Send Buffer
   ↓
Network
   ↓
TCP Receive Buffer
   ↓
Receiver
 

수신 프로그램이 데이터를 읽지 않으면

  • Receive Buffer가 가득 차고
  • TCP Window가 줄어들고
  • 결국 send()가 대기(Block)하거나 느려집니다.

즉, 프로토콜(TCP)이 송신을 제어합니다.


SendMessage(WM_COPYDATA)의 경우

반면 SendMessage()는 네트워크가 아니라 함수 호출에 가깝습니다.

Sender Thread
      ↓
SendMessage()
      ↓
Receiver WindowProc()
      ↓
처리 완료
      ↓
return
      ↓
SendMessage() 리턴
 

즉,

  • 수신 프로그램의 WindowProc가 끝나야
  • SendMessage()가 리턴합니다.

따라서 수신 프로그램이 느리면

Receiver 느림
      ↓
WindowProc 오래 실행
      ↓
SendMessage 대기
      ↓
Sender도 대기
 

결론

사용자 입장에서는 거의 동일한 현상입니다.

항목TCPSendMessage(WM_COPYDATA)
수신이 느리면 송신도 느려짐
송신이 Block될 수 있음
수신이 멈추면 송신도 멈춤
이유 TCP Flow Control 동기 함수 호출

즉, 겉으로 나타나는 현상은 매우 유사합니다.

다만 내부 원리는 다릅니다.

  • TCP는 운영체제의 흐름 제어(Flow Control) 때문에 송신이 멈추고,
  • **SendMessage(WM_COPYDATA)**는 수신 측 WindowProc가 반환할 때까지 호출 스레드가 기다리기 때문에 송신이 멈춥니다.

따라서 현재 올려주신 SendToTail() 구조에서는 디버그 프로그램이 느려지거나 응답하지 않으면 Redis를 처리하는 송신 스레드도 함께 영향을 받을 수 있다고 보시면 됩니다.

Windows 환경에서 TCP 기반 파일전송 시스템을 개발하고,
송신 및 수신 통계를 Redis에 저장하여 자동 비교하는 분석 시스템을 구현하였다.


송신통계)

수신통계)


REDIS 통계요약)

TCP 파일전송 시스템 및 Redis 기반 송수신 통계 분석 시스템

Slide 1. 프로젝트 개요

프로젝트명

TCP File Transfer Statistics Analyzer

프로젝트 목적

Windows 환경에서 대용량 파일을 TCP로 전송하면서

  • 송신 성능
  • 수신 성능
  • TCP Read 특성
  • 데이터 무결성

을 분석하기 위한 시스템을 개발하였다.

또한 Redis를 이용하여 송신 프로그램과 수신 프로그램의 통계를 하나의 프로그램에서 비교 분석하도록 구성하였다.


Slide 2. 전체 시스템 구성

           +----------------------+
           |   송신 프로그램       |
           |  (File Sender)       |
           +----------+-----------+
                      |
                TCP Socket
                      |
                      ▼
           +----------------------+
           |   수신 프로그램       |
           |  (File Receiver)     |
           +----------+-----------+
                      |
          송신통계        수신통계
              ▼              ▼
        Redis Database (6379)
                      |
                      ▼
           +----------------------+
           | Redis Compare Tool   |
           |   통계 비교 분석      |
           +----------------------+
 

총 3개의 프로그램으로 구성된다.

  1. 송신 프로그램
  2. 수신 프로그램
  3. Redis 통계 비교 프로그램

Slide 3. TCP 파일 전송 방식

특징

TCP를 이용하여 파일을 전송한다.

하지만 일반적인 파일전송 프로그램과 다른 특징을 가진다.

특징

  • TCP만 이용
  • 별도의 ACK 없음
  • 일방향 전송
  • 최대 성능 확인 목적

즉,

Sender
    |
    |-------------------------->
    |
Receiver
 

송신측은 계속 보내고

수신측은 계속 받기만 한다.


Slide 4. 패킷 구조

전송 데이터는

[구분자]
[파일 데이터]
[구분자]
[파일 데이터]
...
 

와 같이 구성된다.

구분자는

0x1B 0x1B 0x1B
 

을 사용하였다.

이를 통해

수신측은 데이터 경계를 쉽게 찾을 수 있다.


Slide 5. 송신 프로그램 기능

송신 프로그램은

파일을 일정 크기로 읽어 TCP로 송신한다.

동시에

Read 통계를 수집한다.

수집 항목

  • Read 횟수
  • 총 전송량
  • 최소 Read
  • 최대 Read
  • 평균 Read
  • Read 길이별 개수

송신 완료 후

Redis에 통계를 저장한다.


Slide 6. 수신 프로그램 기능

수신 프로그램은

TCP Socket으로 데이터를 수신한다.

TCP 특성상

한 번 보낸 데이터가

반드시 동일한 크기로 도착하지 않는다.

따라서

수신측 역시

Read 크기를 모두 기록한다.

수신 완료 후

Redis에 통계를 저장한다.


Slide 7. Redis 통계 시스템

Redis에는

송신 통계와 수신 통계를 각각 저장한다.

예)

DownloadStatistics:SEND

DownloadStatistics:RECV
 

Redis Compare 프로그램은

두 데이터를 읽어서

자동으로 비교한다.


Slide 8. 송신 통계 분석

결과

Read 횟수

30,100회
 

총 전송량

523,314,029 Byte
 

평균 Read

17,385 Byte
 

주요 Read 크기

15,360
16,384
17,408
18,432
19,456
 

거의 일정한 크기로 읽고 있음을 확인하였다.


Slide 9. 수신 통계 분석

수신 결과는

Read Count

30,268회
 

으로

송신보다

168회 많았다.

평균 Read

17,289 Byte
 

최대 Read

65,536 Byte
 

까지 발생하였다.

이는 TCP가

데이터를 자유롭게 병합(Coalescing)하기 때문이다.


Slide 10. 송신과 수신 비교

항목송신수신
Read Count 30,100 30,268
Total Bytes 523,314,029 523,314,029
Min Read 1,901 0
Max Read 19,456 65,536
Average 17,385 17,289

분석 결과

총 바이트는

100%

일치하였다.


Slide 11. Read 길이가 달라지는 이유

TCP는

메시지 기반이 아니라

Byte Stream 기반이다.

따라서

송신

19456
19456
19456
 

이라도

수신은

38912

58368

10220

65536
 

다양한 크기로 읽힐 수 있다.

이는 TCP의 정상적인 동작이다.


Slide 12. Redis Compare 결과

Redis Compare는

Read 길이별

송신과 수신을 모두 비교한다.

예)

Length

19456

SEND

5910

RECV

5601
 

차이

309
 

하지만

다른 Read 길이에

합쳐져 존재하므로

전체 Byte는 동일하다.


Slide 13. 파일 무결성 검증

최종 결과

Total Bytes

PASS
 

송신

523,314,029
 

수신

523,314,029
 

100%

동일하였다.

따라서

파일 손실은 발생하지 않았다.


Slide 14. 프로젝트 성과

본 프로젝트를 통해

다음 내용을 확인하였다.

TCP 특징 분석

  • Read 크기는 항상 일정하지 않다.
  • TCP는 데이터를 병합한다.
  • TCP는 데이터를 분할한다.

Redis 활용

  • 통계 중앙 관리
  • 프로그램간 데이터 공유
  • 실시간 비교 가능

개발 성과

  • 대용량 파일 분석 가능
  • 자동 통계 생성
  • 자동 비교 시스템 구축

Slide 15. 결론

본 프로젝트에서는

Windows 환경에서 TCP 기반 파일전송 시스템을 개발하고,

송신 및 수신 통계를 Redis에 저장하여 자동 비교하는 분석 시스템을 구현하였다.

분석 결과,

  • 송신 Read 크기와 수신 Read 크기는 서로 다르게 나타났지만,
  • 총 전송 바이트는 523,314,029 Byte로 정확하게 일치하였다.
  • 이는 TCP의 Byte Stream 특성에 의해 데이터가 병합 또는 분할되어 전달되기 때문이며, 정상적인 동작임을 확인하였다.

또한 Redis 기반 비교 프로그램을 통해 송수신 통계를 실시간으로 검증함으로써 파일 전송의 신뢰성과 무결성을 효과적으로 확인할 수 있었다.

향후에는 다음과 같은 기능을 추가하여 더욱 발전된 파일 전송 분석 시스템으로 확장할 수 있다.

  • 실시간 그래프 기반 통계 모니터링
  • 전송 속도(Throughput) 분석
  • CPU 및 메모리 사용량 분석
  • 다중 파일 동시 전송 비교
  • 전송 오류 및 재전송 통계 분석
  • 웹 기반 대시보드 연동

본 시스템은 TCP 파일 전송 성능 분석과 데이터 무결성 검증을 위한 효율적인 테스트 플랫폼으로 활용될 수 있으며, 네트워크 성능 분석 및 파일 전송 솔루션 개발에도 응용 가능한 기반 시스템이라 할 수 있다.

환경)


위의 환경에서의 송수신 통계)

송신 통계
============================
Read 횟수 : 30,047
총 송신량 : 523,314,029 byte
최소 Read : 4,973 byte
최대 Read : 19,456 byte
평균 Read : 17,416.52 byte

[ Read 길이별 건수 ]
  18,432 byte :      6,001 회
  16,384 byte :      6,055 회
  15,360 byte :      5,928 회
  17,408 byte :      5,976 회
  19,456 byte :      6,086 회
   4,973 byte :          1 회
============================
============================
 Receive Statistics
============================
Read Count : 31,165
Total Received : 523,314,029 byte
Minimum Read : 0 byte
Maximum Read : 65,536 byte
Average Read : 16,791.72 byte

[ Read Length Statistics ]
  16,384 byte :      5,774 count      17,408 byte :      5,682 count      15,360 byte :      5,643 count
  19,456 byte :      5,615 count      18,432 byte :      5,574 count      10,220 byte :        255 count
   1,460 byte :        168 count       5,840 byte :        155 count       2,920 byte :        136 count
   4,380 byte :        135 count       8,760 byte :        113 count      13,140 byte :        107 count
  14,600 byte :        100 count      17,520 byte :         56 count      11,680 byte :         54 count
   6,316 byte :         45 count      16,060 byte :         45 count       7,776 byte :         44 count
  14,052 byte :         42 count      10,696 byte :         42 count       5,292 byte :         40 count
  13,616 byte :         39 count       7,300 byte :         38 count      33,792 byte :         38 count
   4,856 byte :         37 count       6,752 byte :         36 count       8,212 byte :         34 count
   8,648 byte :         34 count      15,076 byte :         34 count       9,236 byte :         34 count
  34,816 byte :         32 count         912 byte :         31 count       3,244 byte :         30 count
  12,592 byte :         29 count       9,672 byte :         29 count       3,832 byte :         29 count
  18,980 byte :         29 count         476 byte :         29 count      10,544 byte :         28 count
   7,624 byte :         28 count      31,744 byte :         28 count       1,936 byte :         27 count
   7,188 byte :         24 count       2,808 byte :         24 count       4,268 byte :         23 count
  35,840 byte :         23 count      32,768 byte :         22 count      11,132 byte :         21 count
   5,140 byte :         21 count      13,028 byte :         21 count       2,220 byte :         21 count
  65,536 byte :         20 count      37,888 byte :         20 count       5,728 byte :         20 count
   9,520 byte :         19 count       3,680 byte :         19 count      36,864 byte :         19 count
   3,396 byte :         18 count       6,600 byte :         17 count      11,568 byte :         17 count
     760 byte :         17 count      12,156 byte :         17 count       2,372 byte :         16 count
   9,084 byte :         15 count      10,980 byte :         13 count       6,164 byte :         13 count
  12,004 byte :         12 count       4,704 byte :         12 count       1,784 byte :         11 count
  10,108 byte :         11 count      30,720 byte :         10 count      16,820 byte :          9 count
     324 byte :          9 count       1,348 byte :          9 count      38,912 byte :          8 count
  16,972 byte :          6 count      13,900 byte :          6 count      18,280 byte :          6 count
   8,060 byte :          5 count      12,440 byte :          5 count      24,272 byte :          5 count
  15,948 byte :          4 count      14,924 byte :          3 count      20,916 byte :          3 count
  28,064 byte :          3 count      19,304 byte :          3 count      19,892 byte :          3 count
  22,660 byte :          2 count      31,572 byte :          2 count      25,296 byte :          2 count
  30,548 byte :          2 count      29,088 byte :          2 count      27,192 byte :          2 count
  28,216 byte :          2 count      17,844 byte :          2 count      14,488 byte :          2 count
  25,144 byte :          2 count      34,056 byte :          2 count      26,756 byte :          2 count
  33,032 byte :          2 count      22,224 byte :          2 count      34,552 byte :          2 count
  21,088 byte :          1 count      22,700 byte :          1 count      45,056 byte :          1 count
  18,168 byte :          1 count      28,976 byte :          1 count      20,764 byte :          1 count
  30,872 byte :          1 count      25,580 byte :          1 count      25,620 byte :          1 count
  16,536 byte :          1 count      21,200 byte :          1 count      24,880 byte :          1 count
  26,604 byte :          1 count      48,128 byte :          1 count      18,868 byte :          1 count
  23,684 byte :          1 count      31,136 byte :          1 count      32,008 byte :          1 count
  33,468 byte :          1 count      34,928 byte :          1 count      51,200 byte :          1 count
  15,512 byte :          1 count      54,272 byte :          1 count      13,464 byte :          1 count
  34,492 byte :          1 count      17,996 byte :          1 count      23,836 byte :          1 count
  29,676 byte :          1 count      35,952 byte :          1 count      28,500 byte :          1 count
  30,984 byte :          1 count      29,524 byte :          1 count       4,973 byte :          1 count
       0 byte :          1 count    
============================

REDIS를 통한 송수신통계 비교)

+ Recent posts