기본 콘텐츠로 건너뛰기

C# Interop - C# 과 C API 상호 운영성

C# 은 강력한 기능과 클래스들을 제공하기만 Window Application 의 작성을 위해서는 C 로 작성된 Library를 가져다 사용해야 하는 경우가 많이 발생한다.

C# 은 기본적으로 포인터를 지원하지 않고, 관리되는 코드의 메모리 관리체계가 근본적으로 C와는 차이가 많기 때문에 C 와의 호환을 위해서는 특수한 기법을 사용해야 한다. 이를 위해서 제공되는 것이 C# 의 PInvoke 와 Marshalling 이다.


1. Using C DLL (PInvoke : Platform Invocation Service)


C# 은 C DLL 의 관리되지 않는 코드 함수를 호출할 수 있도록 플랫폼 호출 서비스 (PInvoke)를 제공한다. 일반적으로 C 로 작성된 DLL 이나 WIN32 API 를 호출하는 용도로 사용된다.

1.1 DLL 함수의 정의

PInvoke 를 이용해서 WIN32 API를 정의하는 방법은 "DllImport" 를 이용하는 것으로 DllImportAttribute 클래스를 사용하여 특성 정의를 하면 된다.

public DllImportAttribute(string dllName)
즉, 사용하려는 DLL 의 파일명을 파라미터로 지정하고 실제 사용하려는 함수는 특성의 바로 아래에 함수 선언을 하면 된다.

예) USER32.dll 의 MessageBox 함수 정의
using System.Runtime.InteropServices

[DllImport(“user32”)]
public static extern int MessageBox(int hWnd, String pText, String pCaption, int uType);
위의 예와 같이 선언하고 C# 코드에서 "MessageBo" 라는 이름으로 실제 함수를 호출할 수 있다. 단, 반드시 해당 함수는 "static extern" 으로 선언되어야 한다.

1.2 DllImport 옵션


DllImport 특성은 몇 가지 옵션을 제공한다. 이 옵션들은 아래와 같으며, 좀 더 자세한 사항은 "DllImportAttribute" 클래스를 참조하면 된다.

필드
설명
Calling Convention
DLL 내의 Export 함수에 대한 Calling Convention을 지정할 수 있다.
Cdecl, Winapi, StdCall 등을 포함하는 CallingConvention Enumerator를 지원하며, 기본값은 StdCall 이다.
CharSet
문자열에 사용할 Character Set을 설정한다.
None(자동), Unicode 값을 가질 수 있다.
Entry Point
DLL 내의 함수가 호출되는 이름을 나타낸다.
이를 이용하면 함수진입점을 지정하여, 선언시 다른 이름으로 별칭을 이용할 수도 있다.

* Calling Convention

DLL 은 VC++  로 생성할 때 Calling Convention 을 설정할 수 있다. 따라서 이 정의를 일치시켜야만 함수가 정상적으로 호출이 될 수 있다. DLL 내의 노출 대상 함수에 대해서 지정하며 Cdel, Winapi, StdCall 등의 열거형 값을 지정할 수 있다. 기본 설정 값은 "StdCall" 이며 대부분은 생략이 가능하다.

// Cdecl 방식의 함수 선언
[DllImport("msvcrt.dll", CharSet=CharSet.Unicode, CallingConvention=CallingConvention.Cdecl)]
public static extern int printf(String format, int i, double d); 

// StdCall 방식의 함수 선언
[DllImport("msvcrt.dll", CharSet=CharSet.Unicode, CallingConvention=CallingConvention.StdCall)]
public static extern int printf(String format, int i, String s);
* CharSet

DLL 함수에서 사용되는 문자열에 사용할 문자 셋을 설정할 수 있다. 역시 DLL 을 생성할 때 사용된 문자 셋과 일치하여야 함수가 정상적으로 호출될 수 있다. None(자동), Unicode, Ansi 등의 열거형 값을 지정할 수 있다.

// Unicode를 사용하도록 설정
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBox(IntPtr hWnd, String text, String caption, uint type);
* EntryPoint

DLL 내의 함수가 호출되는 이름을 나타내는 것으로 정의한 함수명과 실제 DLL 의 함수명이 일치하면 생략 가능하지만, 다른 이름으로 함수를 정의하여야 하는 경우는 이 옵션을 이용하여 연결고리를 만들어 주어야 한다. 즉, 함수에 대한 별칭을 지정한 것이라고 생각하면 된다.

[DllImport(“user32”, CharSet = CharSet.UniCode, EntryPoint = “MessageBoxW”)]
public static extern int MsgBox(int hWnd, String pText, String pCaption, int uType);

1.3 Data Type 의 변환


DLL 함수를 정의할 때 가장 많이 접하게 되는 문제로 C 로 표현된 테이터 형식을 C# 에서는 어떤 데이터 형식으로 처리하여야 하는지의 문제이다. 많은 형식이 존재하므로 아래의 표를 기준으로 확인하면 된다.

C (관리되지 않는 코드)
C# (관리되는 코드)
HANDLE, void* 또는 일반 pointer
IntPtr
BYTE, unsigned char
Byte
short
Short
WORD, unsigned short
Ushort
int
int
UINT, unsigned int
uint
long
int
BOOL, long
int
DWORD, unsigned long
uint
char
char
LPSTR, char*
string 또는 StringBuilder
LPCSTR, const char*
string 또는 StringBuilder
BSTR
string
float
float
double
double
HRESULT
int
VARIANT
object

* String 형식의 변환

관리되지 않는 코드를 사용하는 경우의 char* 는 상황에 따라서 다르게 표현이 되며, String 또는 StringBuilder 를 사용하여 변환할 수 있다. 각 상황에 따른 처리는 아래와 같이 판단하면 된다.

- Call By Value

복제된 값 자체가 전달되는 형식으로 String 형식을 사용하면 된다.

C
UINT GetSystemDirectory(LPTSTR lpBuffer, UINT uSize);
C#
[DllImport( "Kernel32.dll)]
public static extern int GetSystemDirectory(StringBuilder sysDirBuffer, int size);

- Call By Reference (Pointer)

값이 저장된 메모리 포인터를 기반으로 In/Out 이 처리되는 경우이므로 StringBuilder를 사용하면 된다. 

단, String 형식이나 Call By Reference를 사용하는 경우는 DLL 내부에서 생성한 메모리 정보를 .NET 에서 접근하게 되는 상황이므로 잘 못하면 시스템이 죽어버리는 상황이 발생할 수도 있다. 아래의 예제와 같이 DLL 에서 char* 로 메모리를 생성하고, .NET 에서 메모리를 반환 받아서 참조하면 프로그램이 비 정상적으로 종료하게 된다.

C
char* GetLastError();
C#
[DllImport("somedll.dll"]
public static extern string GetLastError();
string s = GetLastError();

이런 경우는 DLL 함수 자체를 변경하거나 함수를 사용하지 않는 쪽으로 검토하여야 한다. 그리고 위의 코드를 DLL을 Call By Reference 로 변경하면 문제를 해결할 수 있다. 즉, 메모리 결과를 직접 반환받지 않고 .NET 에서 생성한 메모리를 포인터로 전달하고 정보를 받아오면 되는 것이다.

C
int GetLastError(char* sError);
C#
[DllImport("somedll.dll"]
public static extern int GetLastError(StringBuilder error);
StringBuilder sb = new StringBuilder();
GetLastError(sb);


2. Marshalling


앞에서 기본적인 데이터형을 사용하는 DLL 함수 호출 방법을 알아 보았다. 그러나 C 로 작성된 DLL 의 대다수는 아래와 같이 struct 형의 파라미터를 사용하는 경우가 많다. 이런 경우는 C# 에서 어떻게 접근을 해야 할까???

Struct MyStruct {
    int n;
    char* s;
}

void SomeFn(MyStruct st);

2.1 StructLayoutAttribute 사용


StructLayout 특성은 StructLayoutAttribute 클래스를 사용한다. 이 클래스의 생성자에 LayoutKind 형식을 설정하면 된다. 즉, StructLayout 은 해당 Structure 가 메모리 상에 어떻게 저장되는지를 의미하는 것이다.
LayoutKind 는 Sequential, Explicit, Auto 등의 열거형 값을 설정할 수 있다. 대부분의 경우는 Sequential 형식이므로 LayoutKind.Sequential 을 사용하면 된다.

[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)] public struct MyStruct { int n; string s; } [DllImport("somedll.dll"] public static extern void SomeFn(MyStruct st);
* Pack 옵션

VC++ 로 DLL 을 만들 때 Byte Align 을 설정할 수 있다. 이는 구조체가 연속적인 메모리 공간에 저장될 때, 성능 향상을 위해서 몇 바이트를 기준으로 정렬될 것인지를 설정하는 것이다. 따라서 DLL 을 사용할 때 DLL 의 설정이 어떤 것인지를 알면 맞춰서 사용하면 된다. 물론 기본 값인 경우는 생략이 가능하다.

[StructLayout(LayoutKind.Sequential, Pack=8)] public struct MyStruct { int n; string s; }
* Structure 내에서의 문자열 배열

Structure 내에서 고정 길이의 문자 배열을 사용하기 위해서는 "MarshalAs" 특성을 사용하여 데이터 형식과 길이를 지정해서 사용하여야 한다.

C에서 아래와 같이 선언되었다면

Struct tpstart_t { char usrname[18]; char cltname[18]; char dompwd[18]; char usrpwd[18]; int flags; }
C# 에서는 아래와 같이 지정하면 된다.

[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)] public struct tpstart_t { [MarshalAs(UnmanagedType.ByValTStr, SizeConst=18)] public string usrname; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=18)] public string cltname; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=18)] public string dompwd; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=18)] public string usrpwd; public int flags; }

2.2 Array Marshalling


함수에 배열을 파라미터로 사용하기 위해서는 In/Out 특성 또는 ref 를 사용하여야 한다.

C 에서 아래와 같이 선언되었다면 

int TestArrayOfInts(int* pArray, int pSize); int TestRefArrayOfInts(int** ppArray, int* pSize); int TestMatrixOfInts(int pMatrix[][COL_DIM], int row); int TestArrayOfStrings(char** ppStrArray, int size); int TestArrayOfStructs(MYPOINT* pPointArray, int size); int TestArrayOfStructs2 (MYPERSON* pPersonArray, int size);
C# 에서는 아래와 같이 선언하면 된다.

[DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern int TestArrayOfInts([In, Out] int[] array, int size ); [DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern int TestRefArrayOfInts(ref IntPtr array, ref int size ); [DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern int TestMatrixOfInts([In, Out] int[,] pMatrix, int row ); [DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern int TestArrayOfStrings([In, Out] String[] stringArray, int size ); [DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern int TestArrayOfStructs([In, Out] MyPoint[] pointArray, int size ); [DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern int TestArrayOfStructs2([In, Out] MyPerson[] personArray, int size );


3. Using Pointer


C 와 C# 의 가장 큰 차이점 중에 하나는 포인터의 존재 여부이다. C 에서는 워낙 광범위하게 사용되므로 C# 에서 호출할 경우는 포인터에 대한 처리가 필수적이다.

.NET 에서는 이런 상호 운영성을 위해서 Marshal 클래스를 제공한다. 이 클래스는 관리되지 않는 메모리를 할당하고, 복사하고, 형식을 변환하는 컬랙션과 메서드들을 제공하고 있다. 즉, 관리되는 메모리와 관리되지 않는 메모리 사이의 상호 변환을 제공하면서 포인터와 관련된 파라미터들을 처리해 주는 것이다.

* IntPtr & Marshal 클래스

IntPtr 은 C# 에서 C 의 포인터를 표현하기 위한 데이터 형식으로 포인터라는 것이 데이터가 존재하는 실제 메모리 상의 주소 값이므로 시스템에 따라서 32bit 또는 64bit 정수 값으로 표현된다.

C 에서는 메모리를 할당하기 위해서 alloc 계열의 함수와 할당된 메모리의 포인터를 파라미터로 받는 함수들이 많이 존재한다. 따라서 이런 함수들을 C# 에서 사용하기 위해서는 필수적으로 IntPtr 을 사용해야 한다. 여기에 Marshal 클래스를 함께 사용하면 된다.

아래의 예는 미들웨어인 TMAX 에서 C#을 사용한 예로 DLL (C)에서 메모리를 할당하고 이 메모리에 특정 Struct 를 복사하는 구조로 작성된 코드를 IntPtr 과 Marshal 클래스를 이용하여 처리한 것이다.

// 접속을 위한 메모리 할당 : DLL 함수 호출 int nAddr = TMaxLib.tpalloc("TPSTART", "", 0); // C Style 메모리주소를 IntPtr로 변환 IntPtr pMem = new IntPtr(nAddr); // TMax 접속을 위한 start_t struct 생성 : StructLayout으로 정의됨 TMaxLib.tpstart_t tpinfop; tpinfop.cltname = ClientName + '\0'; tpinfop.usrname = UserName + '\0'; tpinfop.dompwd = "\0"; tpinfop.usrpwd = "\0"; tpinfop.flags = TMaxLib.TPU_DIP; // 마샬링을 이용하여 start_t 구조체를 할당한 Buffer로 복사 Marshal.StructureToPtr(tpinfop, pMem, true); // T-Max 접속 if(TMaxLib.tpstart(pMem.ToInt32()) == -1) { m_sLastError = "연결에 실패하였습니다."; return false; } // Buffer 메모리 해제 TMaxLib.tpfree(nAddr);
* Marshal 클래스의 주요 메서드들

포인터와 관련된 처리를 위해서 자주 사용되며, 그 중에서 자주 사용하는 메서드들은 아래와 같다.

AllocHGlobal
Unmanaged Memory 영역에 특정 바이트 만큼의 메모리를 할당한다.
함수에 할당된 메모리주소를 파라미터로 넘겨야 하는 경우 사용할 수 있다.
FreeHGlobal
AllocHGlobal로 할당된 메모리를 해제한다.
AllocHGlobal은 Unmanaged Memory를 할당하므로 C와 마찬가지로 직접 메모리를 Delete해야 한다.
Copy
Managed Type에서 Unmanaged type의 Pointer로 데이터를 복사한다.
ReadByte
Unmanaged Memory로부터 데이터를 바이트 단위로 Read한다.
WriteByte
Unmanaged Memory에 데이터를 바이트 단위로 Write한다.
SizeOf
Unmanaged Type의 크기를 리턴한다.
PtrToStructure
Unmanaged Type의 Memory Pointer로부터 Managed Object에 복사한다.
StructureToPtr
Managed Type의 오브젝트를 Unmanaged Memory의 Pointer에 복사한다.

* Function Pointer

C 는 Callback Function 등과 같이 함수 자체의 Pointer를 파라미터로 많이 사용한다. 이 Function Pointer를 C# 에서 구현하기 위해서는 Delegate를 선언해서 처리하며 아래의 코드와 같다.

C 에서 선언된 함수가 아래와 같다면

void TestCallBack(FPTR pf, int value); void TestCallBack2(FPTR2 pf2, char* value);
C# 에서는 아래와 같이 구현하면 된다.


public delegate bool FPtr( int value ); public delegate bool FPtr2( String value ); [DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern void TestCallBack( FPtr cb, int value ); [DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern void TestCallBack2( FPtr2 cb2, String value ); ... public class App { public static void Main() { FPtr cb = new FPtr( App.DoSomething ); LibWrap.TestCallBack( cb, 99 ); FPtr2 cb2 = new FPtr2( App.DoSomething2 ); LibWrap.TestCallBack2( cb2, "abc" ); } public static bool DoSomething( int value ) { Console.WriteLine( "\nCallback called with param: {0}", value ); } public static bool DoSomething2( String value ) { Console.WriteLine( "\nCallback called with param: {0}", value ); } }
* Void Pointer

void 포인터는 기본적으로 UnmanagedType.AsAny 형식의 Object 형식으로 변환하며, void* 은 형식이 미리 예상될 때 임의의 형식으로 overload 하여 사용할 수 있다.

C 에서 아래와 같이 정의 되었다면

void SetData(DataType typ, void* object)
C# 에서는 아래와 같이 구현하면 된다.

[DllImport( "..\\LIB\\PinvokeLib.dll" )] public static extern void SetData( DataType t, [MarshalAs(UnmanagedType.AsAny)] Object o ); [DllImport( "..\\LIB\\PinvokeLib.dll", EntryPoint="SetData" )] public static extern void SetData2( DataType t, ref double i ); [DllImport( "..\\LIB\\PinvokeLib.dll", EntryPoint="SetData" )] public static extern void SetData2( DataType t, String s );


4. Using MFC


문제는 기존에 작성했던 VC++ 라이브러리들을 C# 으로 모두 재 개발을 할 수도 없고, 그렇다고 매번 이렇게 PInvoke/Marshal 을 이용해서 변환해서 사용하는 것도 문제가 된다. 그래서 항상 C 로 만든 로직을 C# 에서 그대로 사용할 수 없는가에 대한 논의는 항상 존재했다.

답은 아주 간단하다. "C 와 .NET 라이브러리를 모두 사용할 수 있는 언어를 이용하면 된다." 이런 것은 관리되는 VC++ 을 사용하면 가능하다. 정확하게는 C++/CLI 라는 것을 사용하는 것이다. 이를 통해서 C 에서 만들어진 라이브러리를 .NET 에서 사용할 수 있는 클래스로 만들 수 있다.

4.1 C++/CLI


관리되는 VC++ 는 Windows SDK, MFC 와 .NET Framework 를 동시에 사용해서 개발이 가능하다. C++/CLI 은 Managed Extensions of C++ 로 현재 버전의 관리되는 VC++ 이전에 지원을 위해서 나왔던 기능이다. 정확하게는 C 와  .NET 의 중간자 역할을 하는 DLL 을 만드는 것이다. 즉, 내부적으로는 C API/MFC 를 사용하고 외부로 노출하는 것은 .NET 으로 하여 .NET 에서 자유롭게 사용하도록 지원하는 기능이다.

- 특징
  • 내부적으로 C API, MFC 등  Visual C++ 의 모든 기능을 사용할 수 있다.
  • C++/CLI 를 이용해서 .NET 의 모든 클래스를 사용할 수 있다.
  • C++/CLI 를 이용해서 .NET 에서 참조만으로 사용 가능한 클래스를 생성할 수 있다.
- API Proxy 클래스

.NET 에서 MFC 기능을 사용하고 싶거나, 복잡한 포인터 구조등을 사용하는 C API 를 사용하고 싶은 경우는 기존의 Visual C# 이나 Visual Basic 에서는 사용이 불가능하거나 사용법이 복잡해서 개발이 용의하지 않다. 그러나 관리되는 VS++ 을 이용해서 C 라이브러리를 .NET 과 호환시켜주는 프록시 클래스를 만들어 사용하면 된다.

4.2 C++/CLI 구문


* 클래스 선언

C++/CLI 에서 관리되는 형식의 선언 방법은 다음과 같다.

ref class Block {}; // reference class value class Vector {}; // value class interface class I {}; // interface class ref class Shape abstract {}; // abstract class ref class Shape2D sealed: Shape{}; // derived class
* 개체 선언

C++/CLI 에서 관리되는 형식의 개체 선언은 아래와 같이 "^" 를 이용해서 선언한다. "^" 는 관리되는 개체 형식의 포인터를 의미한다.

public ref class Form1 : System::Windows::Forms::Form { System::ComponentModel::Container^ components; System::Windows::Forms::Button^ button1; System::Data::DataSet^ myDataSet; System::Windows::Forms::DataGrid^ myDataGrid; };
* 관리되는 개체 생성

C++/CLI 에서 개체 생성은 "gcnew" 를 사용한다.

Button^ button1 = gcnew Button; // managed heap int * pi1 = new int; // native heap Int32^ pi2 = gcnew Int32; // managed heap
* CLR 배열

"array" 키워드를 사용한다.

array<Object^>^ myArr array<int,3>^ myArr
* Property (속성)

"property" 키워드를 사용한다.

public ref class Vector sealed { double _x; public: property double x { double get() { return _x; } void set( double newx ){ _x = newx; } } };

4.3 C++/CLI DLL 만들기


간단한 샘플 DLL 을 만들어 보도록 하자. 내용은 MFC 의 CFtpConnection 클래스를  .NET 에서 사용할 수 있도록 CLR 클래스를 만들고, 동작을 위한 Connection/Disconnection 기능을 가지도록 한다.

우선 아래의 그림과 같이 새 프로젝트를 선택하고 "Visual C++" 카테고리에서 "클래스 라이브러리" 를 선택하여 프로젝트를 새로 생성한다.


프로젝트 속성 페이지에서 "구성 속성 > 일반" 카테고리를 선택해서 "MFC 사용" 항목의 값을 "공유 DLL" 에서 "MFC 사용" 을 선택하도록 한다.


그리고 MFC 를 사용하기 위해서 Stdafx.h 에 아래와 같이 MFC Header 파일을 포함시킨다.

#include <afxwin.h> #include <afxinet.h>
이제 .NET 에서 사용할 CLR 클래스를 작성한다. 실제 내부적으로 사용할 MFC 의 CFTPConnection 클래스를 위한 변수를 멤버로 추가하고, 사용할 리소스를 반환하여야 하므로 IDisposable 인터페이스를 구현한다. 그리고 접속/해제를 위한 메서드를 추가한다. 당연히 .NET 에서 사용할 것이므로 CLR Type 을 사용하여야 한다.

아래의 코드는 FTPProxySample.h 파일에 작성한다.

namespace FTPProxySample { public ref class FtpConnection : public IDisposable { public: FtpConnection(); ~FtpConnection(); void Open(String^ serverURL, int Port, String^ ClientName, String^ UserID, String^ Pwd); void Close(); private: CInternetSession* m_pSession; CFTPConnection* m_pFtpConn; }; }
이제 실제 구동을 위한 코드를 작성하도록 한다. FTPProxySample.cpp 파일에 작성한다.

namespace FTPProxySample { FtpConnection::FtpConnection() { m_pSession = NULL; } FtpConnection::~FtpConnection() { m_pFtpConn = NULL; Close(); System::GC::SupressFinalize(this); } void FtpConnection::Open(String^ ServerURL, int Port, String^ ClientName, String^ UserID, String^ Pwd) { CString serverURL(ServerURL); CString password(Pwd); CString userID(UserID); CString clientName(ClientName); this.m_pSession = new CInternetSession(clientName); // FTP 접속 try { this.m_pFtpConn = this.m_pSession->GetFtpConnection(serverURL, userID, password, Port); } catch(CInternetException* pe) { // Exception 처리 } } void FtpConnection::Close() { if (this.m_pSession != NULL) { this.m_pSession->Close(); delete this.m_pSession; this.m_pFtpConn = NULL; this.m_pSession = NULL; } } }
이제 컴파일을 하면 .NET 에서 참조해서 사용할 수 있는 FTPProxySample.dll 이 만들어진다.

4.4 Using C++/CLI DLL


위에서 만든 DLL 은 .NET 에서 사용하기 위해서 작성한 것이므로 어떤 .NET 용 프로젝트에서라도 참조하여 사용할 수 있다. 아래의 그림은 샘플 테스트 프로젝트에 참조한 것을 나타내고 있다.

실제 사용은 아래의 코드와 같이 일반  .NET DLL 참조하듯이 사용하면 된다.

private void button1_Click(object sender, EventArgs e) { using (var ftp = new FTPProxySample.FtpConnection()) { try { ftp.Open("server-url", 21, "Test", "id", "password"); MessageBox.Show("Success"); } catch (Exception ex) { MessageBox.Show(ex.Message); } } }
이상으로 해서 .NET 과 C 와의 연동을 위한 상호 운영성에 대해서 정리해 보았다. 실제 적용하는 시점에는 좀 더 다양한 상황이 발생할 수 있음로 내용을 주지하여 잘 적용하여야 한다.

댓글

  1. 감사합니다. 좋은 글 잘 읽었습니다.

    근데, "* CLR 배열 "array" 키워드를 사용한다." 바로 아래의 예제 코드가 드래그를 해야 보입니다. 아무래도 색상 처리가 잘못된 것 같은데, 약간의 옥의 티를 발견해 말씀드려요~

    아무튼, 좋은 글 잘 읽었습니다.

    답글삭제
  2. 사이트 관리에서 손을 놓고 있었더니 비슷한 의견들이 많으시네요. 조만간 사이트 갱신을 한번 하겠습니다. 감사합니다. 오늘도 좋은 하루 되세요.

    답글삭제
  3. 정말 정리를 잘하셨습니다. 잘 보고 갑니다.

    답글삭제
    답글
    1. 도움이 되셨기를 바랍니다.
      감사합니다. 오늘도 좋은 하루 되세요.

      삭제

댓글 쓰기