옆히
시작하세요! C# 12 프로그래밍 - 1부 본문
시작하세요! C# 12 프로그래밍 - 정성태 지음/위키북스 |
도서: 시작하세요! C# 12 프로그래밍 (sysnet.pe.kr)
추상화, 다형성, 상속, 캡슐화, 정보은닉성, 재사용
기존 프로시저 지향 패러다임에서는 메소드만 묶고 변수는 모두 공용으로 해서 초기 설계는 쉽지만 뒤로 갈수록 프로그램 변경이 어려웠던 반면 객체 지향에서는 속성과 메서드 모두 하나로 묶어서 보아 초기 설계는 어렵지만 프로그램 유지보수가 용이함 -> 속성을 묶는게 핵심
1장
//C# 컴파일러는 소스코드를 기계어가 아닌 IL(Intermediate Language)이라고 하는 중간 언어로 실행 파일(예를 들어, EXE/DLL) 내부에 생성한다. 또한 프로그램이 시작하자마자 닷넷 런타임을 로드하는 코드를 자동으로 실행 파일 내부에 추가한다. 따라서 사용자가 c3으로 개발된 애플리케이션을 실행하면 내부적으로 닷넷 런타임이 먼저 로드된다. 이어서 닷넷 런타임은 실행 파일 내에 있는 중간 언어(IL: Intermediate Language) 코드를 로드해서 본격적인 실행 단계에 들어선다.
//C# -> 닷넷 호환 언어
자바의 VM에서 중간 언어를 특별히 바이트코드(Bytecode)라고 부르는 것과 마찬가지로 닷넷 런타임에서는 이를 CIL(Commom Intermediate Language)이라고 하며 보통은 줄여서 IL 코드, MSIL 코드라고 부름
//C# 컴파일러로 빌드 -> IL코드로 변환(가상 머신에서 실행된다는 의미에서 일종의 기계어와 유사함)
//공용 타입 시스템(CTS: Common Type System)
닷넷 호환 언어가 지켜야 할 타임(Type)의 표준 규격을 정의한 것; 새로운 언어를 만들어 닷넷 런타임에서 실행하고 싶다면 CTS 규약을 만족하는 한도 내에서만 구현할 수 있다.
//공용 언어 사양(CLS: Common Language System)
닷넷 호환 언어가 지켜야 할 최소한의 언어 사양을 정의한 것
//기존 네이티브 언어에서 정의한 클래스를 컴파일해서 생성한 실행 파일로부터 개발자가 만든 클래스의 어떠한 정보도 알아낼 수 없지만 닷넷 런타임에서 동작하는 실행 파일은 완전하게 자기 서술적인(self-descriptive) 메타데이터를 제공하며, 외부에서는 이런 정보를 리플렉션(Reflection)이라는 기술을 통해 사용할 수 있다.
//어셈블리, 모듈, 매니페스트
C#으로 윈도우 환경에서 프로그램을 만드는 경우, 대개 EXE, DLL 파일을 만들게 되는데, 닷넷에서는 이런 실행 파일을 어셈블리(Assembly)라고 한다. 어셈블리는 1개 이상의 모듈(Module)로 구성되는데, 이때 모듈 하나당 한 개의 파일이 대응된다. 이런 모듈 중 하나는 반드시 다른 모듈의 목록을 관리하는 매니페스트(manifest) 데이터를 담고 있어야 한다.
//공용 언어 기반구조(CLI: Common Language Infrastructure)
CIL는 CTS 명세를 포함하며, 중간 언어에 대한 코드 정의, 메타데이터와 그것을 포함하는 이진 파일(binary file)의 구조까지 표준 사양으로 기술하고 있다.
//C#은 닷넷 환경을 위한 IL 코드를 생성하는 컴파일러에 불과하므로 이 책에서 배우는 모든 것은 '문법적인 요소'만 제외하면 모두 닷넷의 영역에 해당한다. C#을 배운다는 것은 곧 닷넷을 공부한다는 의미다.
//공용 언어 런타임(CLR: Common Language Runtime)
과거 닷넷 프레임워크가 구현한 CLR은 CLI 사양을 따르는 가장 대표적인 VM이었다. 이후 닷넷 코어가 나오면서 CLR을 다중 플랫폼에 맞게 재구현한 CoreCLR이 나왔다. 이러한 CLR에는 두 가지 큰 기능이 있는데, 하나는 중간 언어를 JIT 컴파일러를 이용해 기계어로 변환하는 것이고, 다른 하나는 가자비지 수집기(GC: Garbage Collector)를 제공해 동적 메모리 할당 및 회수를 지원하는 것이다.
CLR 자체를 관리 환경(Managed Environment)이라고도 하고, CLR이 로드되는 프로세스를 기존의 네이티브 프로세스와 구별해 관리 프로세스(Manage Process)라고 한다. ++닷넷 호환 언어 == 관리 언어(Managed Language)
CLI, CLR/CoreCLR, 닷넷은 보통 구분없이 사용된다; C#과 같은 언어는 닷넷 호환 언어라는 말보다는 CLI 호환 언어라고 불러야 하는 것이 맡다.
//닷넷 = CoreCLR + 부가 구성 요소(BCL(Base Class Library) + 기타 파일)
3장
실수형 기본 double
실수 리터럴: 2.4f (float); 2.445m (decimal)
#decimal vs double 차이
decimal은 고정 소수점 방식이라서 연산 속도가 빠르고, 수의 정확성이 높은 대신 메모리를 많이 씀
double은 부동 소수점 방식이라서 적은 메모리로 큰 수를 저장 가능하나 정확성이 떨어짐
C#에서는 byte name = 2123; 라고 쓰면 컴파일 오류
char vs string
차이점은 크게 2가지로 글자수의 제한과 내용물의 차이입니다. char 같은 경우는 내용물이 1개의 문자로 제한되는 반면에 String은 그런 제한이 없이 문자를 담을 수 있습니다. 내용물의 차이는 char는 해당 변수 안에 값을 직접적으로 가지고 있고 해당 값이 있는 좌표를 가지고 있지 않습니다. String은 char와 달리 클래스타입의 변수이기에 생성 시 해당 변수 안의 값을 가지는 게 아니라 임의로 만들어진 값이 들어있는 위치의 좌표를 내용물로 가집니다. char a= 'a' 면 a라는 변수 안에는 a라는 값이 있지만 String abc='abc'는 abc라는 변수 안에는 'abc'가 아니라 'abc'라는 텍스트가 위치한 좌표 "xxxxxxx(임의)' 를 가집니다. 이 차이점에서 발생하는 것이 ==의 사용가능 여부입니다. char 같은 경우는 값이 같다면 ==를 사용할 수 있지만 String은 내용이 같더라도 String 생성시마다 서로 다른 좌표가 생성되기에 ==를 사용하면 같지 않다는 결과가 나옵니다. 이를 위한 해결책으로 .equals() 를 이용하여 스트링의 내용물이 실제 같은지를 확인하여 같은 지 여부를 파악합니다.
기존 네이티브 환경에서는 C/C++ 언어 등으로 프로그램을 만들면 메모리 할당과 해제를 반드시 쌍으로 맞춰야만 했다. 반면 C# 프로그램이 동작하는 관리 환경의 경우 개발자는 오직 할당만 하고 해제는 관리 환경 내의 특정 구성 요소가 담당한다. 그것을 가비지 수집기라고 한다.
배열: dataType[] arr = new dataType[구성요소의 개수]
다차원 배열: dataType[,,] arr = new dataType[dim, row, col]; dataType[,] ...; ...
가변 배열(jagged array): dataType[][] arr = new dataType[m][n];
다차원 배열은 콤마를 이용해 차수를 구분하는 반면, 가변 배열은 각 차수마다 대괄호를 사용한다는 특징이 있다. 가변 배열이 메모리 낭비가 적다.
if(x > 10 || n++ > 10) 문장;
단축 평가(short-circuit evaluation) 되어서 뒤의 n++ > 10이 실행 되지 않음 n을 증가시키려는 의도가 있다면 실행 불가
switch(인스턴스){ ... }
인스턴스 == 정수형, 문자영, 불린형, 열거형 가능
C# 스위치 구문은 case에서 break 생략이 불가능하다. 단, case에 내용이 없으면 break 생략이 가능하고 or처럼 사용 가능
for(초기식; 조건식; 반복식) 구문; 초기식 -> 조건식 -> 구문 -> 반복식 -> 조건식 -> ...
foreach ( 표현식요소의_자료형 변수명 in 표현식) 구문; C++의 for (i in arr)랑 비슷
C#에서 제공되는 jump statements 종류: break, continue, goto, return, throw
goto은 보통 중첩 루프에서 탈출하는 경우 유용하다
4장
클래스로 정의된 타입은 string처럼 모두 '참조형'으로 분류됨.
정의된 속성 C#에서는 field, 행위(behavior)은 method라고 부름
메서드를 호출하는 측에서 전달하는 값은 메서드의 인자(augment)라고 한다.
개발자가 명시적으로 생성자를 정의한 경우 컴파일러는 기본 생성자를 추가하지 않음
C++ 파괴자 -> C# 종료자(finalizer) dtor (<->ctor)
GC 입장에서는 일반 참조 객체와는 달리 종료자가 정의된 클래스의 객체를 관리하려면 더 복잡한 과정을 거쳐야 하므로 성능 면에서 부하를 줄 수 있기 때문이다. 이 경우 기준은 하나다. 닷넷이 관리하지 않는 시스템 자원을 얻은 경우에만 종료자를 정의하라. -> 추후 네이티브 프로그램과의 협업을 다룰 때 다룸
인스턴스 멤버 <- 인스턴스 필드, 인스턴스 생성자, 인스턴스 메서드
정적 멤버 <- 정적 필드, 정적 메서드
class person //Singleton class
{
static public Person President = new Person("대통령") // public 정적 필드
string _name;
private Person(string name) // private 인스턴스 생성자
{
_name = name;
}
public void DisplayName() //public 인스턴스 메서드
{
Console.WriteLine(_name);
}
}
정적 필드를 사용하는 전형적인 패턴 가운데 대표적으인 한가지로 특정 클래스의 인스턴스를 의도적으로 단 한 개만 만들고 싶은 경우, 클래스 밖에서 해당 클래스의 인스턴스를 만들지 못하게끔 생성자를 private 접근 제한자로 명시하고 단 하나의 인스턴스만 클래스 내부에서 미리 생성해 두는 것으로 원하는 바를 이룰 수 있다.
단일 시스템 자원을 책임지는 타입이 필요할 때 싱글턴 클래스를 만들어 다른 클래스에 기능을 노출하는 용도
정적 메서드 안에서는 인스턴스 멤버에 접근할 수 없다는 특징이 있다. 이는 정적 메서드가 new로 할당된 객체가 없는 상태에서도 호출되는 메서드라는 점을 생각하면 쉽게 이해할 수 있다. (Console.WriteLine() 도 정적 메소드다)
#Main 메서드
프로그램은 CPU에 의해 순차적으로 실행된다는 특징을 지닌다. 가장 처음 실행되는 명령어, 다른 말로 진입점(entry point)이라고도 하는데, C#은 다음과 같은 약속을 따르는 메서드를 최초로 실행될 메서드라고 한다.
- 메서드 이름은 반드시 Main
- 정적 메서드여야 함
- Main 메서드가 정의된 클래스의 이름은 제한이 없다. 하지만 2개 이상의 클래스에서 Main 메서드를 정의하고 있다면 C# 컴파일러에게 클래스를 지정해야 한다.
- Main 메서드의 반환값은 void 또는 int만 허용된다.
- Main 메서드의 매개변수는 없거나 string 배열만 허용된다
이 규칙을 만족하는 메서드를 정의하면 C# 컴파일러는 자동으로 그 메서드를 시작점으로 선택해 EXE 파일을 생성한다.
메인 메서드의 반환값 -> 오류 여부를 판단하는데 사용
메인 메서드의 인자 -> 명령행에서 exe 프로그램을 실행할 때 함께 입력되는 문자열을 공백으로 구분해 차례차례 배열에 담아 활용하는 것이 가능함
정적 생성자(static constructor cctor)는 기본 생성자에 static 예약어를 붙인 경우로 클래스에 단 한 개만 존재할 수 있고, 주로 정적 멤버를 초기화하는 기능을 하기 때문에 형식 이니셜라이저(type initializer)라고도 한다. 정적 생성자는 단 한 개만 정의할 수 있고 매개변수를 포함할 수 없다.
정적 필드에 초기화 코드도 포함돼 있고 동시에 정적 생성자도 정의해 뒀다면 C# 컴파일러는 사용자가 정의한 정적 생성자의 코드와 초기화 코드를 자동으로 병합해서 정의한다. 이 규칙은 인스턴스 필드와 기본 생성자 간에도 동일하게 적용된다.
정적 생성자는 클래스의 어떤 멤버든 최초로 접근하는 시점에 단 한 번만 실행된다는 점을 기억해 두자. 정적 멤버를 처음 호출할 경우이거나 인스턴스 생성자를 통해 객체가 만들어지는 시점이 되면 그 어떤 코드보다도 우선적으로 실행된다.
className.stvar(C#) <-> className::stvar(C++)
namespace ContexName { class ClassName { ... } }
[네임스페이스].[클래스]
namespace는 그 안에 또 다른 namespace를 중첩하는 것이 가능
using 예약어를 사용하면 C# 컴파일러가 알아서 객체가 속한 네임스페이스를 찾아내 오류 없이 컴파일 한다(C/C++ #include 같은거); 다른 곳에 위치한 곳 pp.291 프로젝트 구성 참고
using 문은 반드시 파일의 첫 부분에 있어야 한다. 어떤 코드도 using 문 앞에 와서는 안 된다.
현실적으로 보면 네임스페이스가 이름 충돌 때문에 사용되는 경우는 많지 않다. 대신 클래스의 소속을 구분하는 데 사용되는 것이 더 일반적이다.
소스코드 파일에 단 하나의 namespace만 정의한다면 첫 번째 예제 코드처럼 namespace를 블록 없이 정의하는 것이 가능하다.(C# 10부터 지원 File Scoped Namespaces)
.NET 7을 지원하는 프로젝트부터 C# 10 이상의 컴파일러가 기본적으로 System 네임 스페이스를 추가 해줌(System.Console.WriteLine() -> Console.WriteLine())
#FQDN(Fully Qualified Domain Name)
C# 프로그래밍에서는 일반적으로 네임스페이스가 생략된 클래스명과 구분해서 클래스명에 네임스페이스까지 함께 지정하는 경우 특별히 FQDN이라고 한다. ++Console 클래스의 FQDN은 System.Console
#encapsulation
관련성 있는 데이터와 그 데이터를 다루는 메서드를 객체 안에 구현하는 것이 일반적인 통념이고, 더 나아가서는 객체의 밖에서 알 필요가 없는 내부 멤버를 숨기기도 하는데, 이를 두고 캡슐화(encapsulation)라는 용어를 사용한다.
#접근 제한자(access modifier)
- private 내부에서만 접근을 허용한다
- protected 내부에서의 접근과 함께 파생 클래스에서만 접근을 허용한다
- public 내부 및 파생 클래스에서의 접근뿐만 아니라 외부에서도 접근을 허용한다.
- internal 동일한 어셈블리 내에서는 public에 준한 접근을 허용한다
- internal protected 동일한 어셈블리 내에서 정의된 클래스이거나 다른 어셈블리라면 파생 클래스인 경우에 한해 접근을 허용한다(protected internal로 지정 가능)
class 정의에서 접근 제한자를 생략한 경우 기본적으로 internal로 설정되는 반면, class 내부의 멤버에 대해서는 private으로 설정된다.
#information hiding
클래스 입장에서 '정보'라고 불리는 것은 멤버 변수를 일컫는데, 외부에서 이 멤버 변수에 직접 접근할 수 없게 만드는 것이 바로 정보 은닉에 해당한다.
#정보은닉의 원칙
- 특별한 이유를 제외하고는 필드를 절대 public으로 선언하지 않는다(그런데 그럴 만한 특별한 이유가 과연 있을까?)
- 접근이 필요할 때는 접근자/설정자 메서드를 만들어 외부에서 접근하는 경로를 클래스 개발자의 관리하에 둔다.
필드에 읽고 쓰기가 적용될 때는 관례적으로 get과 set이라는 단어를 각각 사용한다. 그리고 멤버 변수에 대해 get/set 기능을 하는 메서드를 특별히 접근자 메서드(getter), 설정자 메서드(setter)라고 한다.
#property
접근자/설정자 메서드를 둬서 필드 접근에 대한 단일 창구를 제공하는 것은 바람직하지만 호출을 위한 메서드 정의를 일일이 코드로 작성하자면 분명 번거로울 것이다. 이 같은 단점을 보완하기 위해 C#에서는 특별히 프로퍼티 문법을 제공한다.
(property, attrubute 둘다 속성으로 번역되었지만 C#에서는 property 문법, field에 해당 둘이 다름; C#의 프로퍼티는 보통 public으로 되는 경우가 많아서 '공용 속성'이라고 구분해서 부르기도 한다.)
class Circle
{
double pi = 3.14;
public double Pi
{
get { return pi; }
set { pi = value; } // set 블럭 내부에서만 사용 가능한 value 키워드 사용
}
}
프로퍼티는 메서드의 특수한 변형; C# 컴파일러가 빌드하는 시점에 자동으로 다음과 같은 메서드로 분리해서 컴파일 한다.
C#의 프로퍼티는 접근자/설정자 메서드를 간편하게 만들어주는 도우미 성격의 구문일 뿐이다.
#상속(inheritance)
accessModifier class derivedClass : baseClass { .. }
콜론(:)을 이용해 부모 클래스의 기능을 상속받음; 상속받은 클래스는 부모의 속성과 행위를 접근 제한자 규칙에 따라 외부에 제공한다.
private 접근 제한자가 적용된 멤버는 오직 그것을 소유한 클래스에서만 접근할 수 있다. 따라서 자식 클래스일지라도 부모의 private 멤버에 접근하는 것은 허용되지 않는다.
private 처럼 외부에서의 접근은 차단하면서도 자식에게는 허용하고 싶다면 protected 사용
sealed class className { ... }
sealed 예약어를 사용하면 상속을 의도적으로 막을 수 있다. 일례로 우리가 자주 쓰는 string 타입은 상속을 더 받지 못하도록 제한돼 있는데, 이는 sealed 예약어가 적용돼 있기 때문이다.
#C#의 상속은 단일 상속(single inheritance)만 지원한다.
class Computer {}
class Monitor {}
clase Notebook : Computer, Monitor // 컴파일 오류 발생
{
}
C#은 '계층 상속'은 가능하지만 동시에 둘 이상의 부모 클래스로부터 다중 상속(multiple inheritance)을 받는 것은 허용하지 않는다.
#형 변환
기본 자료형의 형 변환 관계를 보면 정수 -> int -> short -> short 순으로 '일반화 -> 특수화'하는 모습을 보이고 있다. 이러한 형 변환에 적용해 정리하면 다음과 같다.
- 암시적 형 변환: 특수화 타입의 변수에서 일반화된 타입의 변수로 값이 대입되는 경우; short a = 100; int b = a;
- 묵시적 형 변환: 일반화된 타입의 변수에서 특수화된 타입의 변수로 값이 대입되는 경우; int c =100; short d = (short)c;
타입의 부모(일반화)/자식(특수화) 관계에서도 동일하게 적용된다.
Notebook noteBook = new Notebook();
Computer pc1 = noteBook; //암시적 형 변환 가능
Computer pc = new Computer;
Notebook notebook = (Notebook)pc;
//명시적 형 변환, 컴파일은 가능 그러나 실행 단계에서 오류가 발생한다
//pc 메모리를 할당할 때 Notebook에만 있는 멤버를 고려하지 않았기 때문이다
Notebook noteBook = new Notebook();
Computer pc1 = noteBook; //부모 타입으로 암시적 형 변환
Notebook pc2 = (Notebook)pc1; //다시 본래 타입으로 명시적 형 변환
자식 클래스의 인스턴스를 부모 객체의 배열에 담을 수 있는 것도 암시적 형 변환 덕분임
public void TurnOff(Computer device){ ... } 메소드에 Computer device에 Computer의 자식 클래스를 넣어도 암시적 형변환으로 가능
#as, is 연산자
닷넷 프로그램에서 오류를 발생시키는 것은 내부적으로 제법 부하가 큰 동작에 속한다. 따라서 오류를 발생시키지 않고도 형 변환이 가능한지 확인할 수 있는 방법이 필요했고 이를 위해 as연산자가 추가됨
as는 형 변환이 가능하면 지정된 타입의 인스턴스 값을 반환하고, 가능하지 않으면 null을 반환하기 때문에 null 반환 여부를 통해 형 변환이 성공했는지 판단할 수 있다.
as 연산자는 참조형 변수에 대해서만 적용할 수 있고 참조형 타입으로의 체크만 가능하다
int n =5; string txt = "txt";
n as string; txt as int;로 사용하면 컴파일 에러가 발생함
as가 형 변환 결괏값을 반환하는 반면, is 연산자는 형 변환의 가능성 유무를 불린형의 결괏값(true/false)으로 반환한다. as/is 연산자를 언제 쓰느냐에 대한 기준은 명확하다. 형 변환된 인스턴스가 필요하다면 as를 사용하고 필요 없다면 is를 사용하면 된다. (int n = 5; if (n is string){ ... }; 캐스팅은 필요없으니 is)
is 연산자가 as 연산자와는 다른 또 하나의 특징은 대상이 참조 형식뿐 아니라 값 형식에도 사용할 수 있는다는 점이다.
#모든 타입의 조상:System.object
클래스를 정의할 때 부모 클래스를 명시하지 않는다면 C# 컴파일러는 기본적으로 object라는 타입에서 상속받는다고 가정하고 자동으로 코드를 생성한다. :object 자동
결국 C#에서 정의되는 모든 클래스의 부모는 object가 된다.
object는 그 자체가 참조형이지만, 값 형식의 부모 타입이기도 하다. 참조 형식과 값 형식은 처리 방식이 매우 다른데, 이러한 불일치를 구분하기 위해 닷넷에서는 모든 값 형식을 System.ValueType 타입에서 상속받게 하고 있으며, 다시 System.ValueType은object를 상속받는다. 즉, 값 형식은 System.ValueType으로부터 상속받은 모든 타입을 의미하고, 참조 형식은 object로부터 상속받은 타입 가운데 System.ValueType의 하위 타입을 제외한 모든 타입을 의미한다.
C# | 대응되는 닷넷 프레임워크 형식 | 특징 |
object | System.Object | 모든 C# 클래스의 부모 |
namespace System;
public class Object
{
public virtual bool Equals(object obj);
public virtual int GetHashCode();
public Type GetType();
public virtual string ToString();
}
#ToString()
ToString 메서드를 호출하면 해당 인스턴스가 속한 클래스의 전체이름(FQDN)을 반환한다.
ToString 메서드는 자식 클래스에서 기능을 재정의할 수 있는데, string을 비롯해서 C#에서 제공되는 기본 타입(short, int, ...)은 모두 ToString을 클래스의 전체 이름이 아닌 해당 타입이 담고 있는 값을 반환하도록 변경했다.
#GetType()
클래스를 '객체지향' 관점으로 바라본다면, 클래스 역시 속성으로 클래스 이름을 담고 있으며, 필드, 메서드, 프로퍼티와 같은 멤버를 담고 있는 또 다른 타입으로 볼 수 있다. C#에서는 개발자가 class로 타입을 정의하면 내부적으로 해당 class 타입의 정보를 가지고 있는 System.Type의 인스턴스를 보유하게 되고, 바로 그 인스턴스를 가져올 수 있는 방법이 GetType 메서드를 통해 제공된다. typeof 예약어; .FULLName, .IsClass, .IsArray
#Equals()
Equals 메서드는 값을 비교한 결과를 불린형으로 반환한다.
값 형식에 경우 해당 인스턴스가 소유하고 있는 값을 대상으로 비교하지만, 참조 형식에 대해서는 할당된 메모리 위치를 가리키는 식별자의 값이 같은지 비교한다.(힙에 할당된 데이터 주소를 가리키고 있는 스택 변수의 값을 비교함)
string txt1 = new string(new char[] {'t', 'e', 'x', 't'});
string txt2 = new string(new char[] {'t', 'e', 'x', 't'});
Consonle.WirteLine(txt.Equals(txt2)); //출력 결과: True
#GetHashCode()
GetHashCode 메서드는 특정 인스턴스를 고유하게 식별할 수 있는 4바이트 int 값을 반환한다. 한 가지 기억해 둬야 할 점은 GetHashCode가 Equals 메서드와 연계되는 특성이 있다는 점이다. Equals의 반환값이 True인 객체라면 서로 같음을 의미하고, 그렇다면 그 객체들을 식별하는 고윳값 또한 같아야 한다. 보통 Equals 메서드를 하위 클래스에서 재정의하면 GetHashCode까지 재정의하는데, 이를 따르지 않으면 컴파일 경고가 발생한다.
object에서 정의된 GetHashCode는 참조 타입에 대해 기본 동작을 정의해 뒀는데, 생성된 참조형 타입의 인스턴스가 살아 있는 동안 닷넷 런타임 내부에서 그러한 인스턴스에 부여한 식별자 값을 반환하기 때문에 적어도 프로그램이 실행되는 중에 같은 타입의 다른 인스턴스와 GetHashCode 반환값이 겹칠 가능성은 많지 않다. 반면 값 타입에 대해서 GetHashCode의 동작 방식을 재정의해서 해당 인스턴스가 동일한 값을 가지고 있다면 같은 해시코드를 반환한다.
자료형이 4바이트가 넘어가면 해시 충돌이 날 수있음
#모든 배열의 조상: System.Array
멤버 | 타입 | 설명 |
Rank | 인스턴스 프로퍼티 | 배열 인스턴스의 차원(dimention) 수를 반환한다 |
Length | 인스턴스 프로퍼티 | 배열 인스턴스의 요소(element) 수를 반환한다 |
Sort | 정적 메서드 | 배열 요소를 값의 순서대로 정렬한다 |
GetValue | 인스턴스 메서드 | 지정된 인덱스의 배열 요소 값을 반환한다 |
Copy | 정적 메서드 | 배열의 내용을 다른 배열에 복사한 |
배열은 System.Array로부터 상속받은 참조형 타입임
#this
클래스 내부의 코드에서 객체 자신을 가리킬 수 있는 방법 -> this 예약어
- 멤버 변수임을 명확하게 인식할 수 있게 this를 명시
- 메서드의 매개변수와 클래스에 정의된 필드의 이름이 같을 경우
- this를 이용한 생성자 코드 재사용
class Book
{
string title;
decimal isbn13;
string author;
public Book(string title) : this(title, 0)
{
}
public Book(string title, decimal isbn13) : this(title, isbn13, string.Empty)
{
}
public Book(string title, decimal isbn13, string author)
{
this.title = title;
this.isbn13 = isbn13;
this.author = author;
}
public Book() : this(string.Empty, 0, string.Empty)
{
}
}
중복 코드 제거 원칙
#this와 인스턴스/정적 멤버의 관계 158~160
인스턴스 멤버와 정적 멤버의 차이를 this 예약어를 사용할 수 있느냐/없느냐로 나눌 수 있다. this는 new로 할당된 객체를 가리키는 내부 식별자이므로 클래스 수준에서 정의되는 정적 멤버는 this 예약어를 사용할 수 없다.
인스턴스 메서드는 인자를 무조건 1개 이상(컴파일러가 this 식별자를 첫번째 인수로 넣어줌) 더 받게 돼 있으므로 내부에서 인스턴스 멤버에게 접근할 일이 없다면 정적 메서드로 명시하는 것이 성능상 유리할 수 있다.(최근 메모리나 CPU에서 메서드가 인자를 하나 더 받는다고 성능상 크게 문제되는 경우가 많지 않지만 비주얼 스튜디오의 코드분석기와 같은 도구는 this가 필요 없는 메서드를 정적으로 정의하지 않은 경우 성능 경고를 발생시킨다.)
#base
this 예약어가 클래스 인스턴스 자체를 가리키는 것과 달리 base 예약어는 '부모 클래스'를 명시적으로 가리키는 데 사용된다. this와 마찬가지로 부모 클래스의 멤버를 사용할 때 base 키워드가 생략된 것이나 다름없다고 보면 된다.
ex)상속 클래스가 부모 메서드를 쓸 때 base.methodNameByParent base가 생략되어도 가능
생성자에서 사용되는 패턴도 this와 유사하다.
class Book
{
decimal isbn13;
public Book(decimal isbn13)
{
this.isbn13 = isbn13;
}
}
class EBook : Book
{
public EBook() // 오류 발생
{
}
}
자식 클래스를 생성한다는 것은 곧 부모 클래스의 생성자도 함께 호출한다는 것인데, 부모 클래스에 isbn은 private한 멤버이기 때문에 자식 클래스에서 부모 클래스의 초기화까지 할 수 없다.
class Book
{
decimal isbn13;
public Book(decimal isbn13)
{
this.isbn13 = isbn13;
}
}
class EBook : Book
{
public EBook() : base(0)
{
}
public EBook(decimal isbn) : base(isbn) // 또는 이렇게 값을 연계하는 것도 가능하다.
{
}
}
생성자는 그것이 정의된 클래스 내부의 필드를 초기화 하는 일만 담당하면 되고, 부모 클래스의 필드는 부모 클래스의 생성자가 초기화할 것이므로 맡겨버린다.
#다형성(polymorphism)
#메서드 오버라이드
class mammal
{
virtual public void Move()
{
Console.WriteLine("이동한다");
}
}
class human : mammal
{
override public void Move()
{
Console.WriteLine("두발로 이동한다");
}
}
class Program
{
static void Main(string[] args)
{
human kim = new human();
kim.Move();
mammal a = kim; //형 변환이 되어도 특성이 유지됨
a.Move();
}
}
virtual/override 예약어를 적용함으로써 부모에서 정의한 메소드를 자식 클래스의 인스턴스에 따라 다양하게 재정의(override)할 수 있고, 인스턴스가 어떤 타입으로 형 변환돼도 그 특징이 유지됨. 다형성의 한 사례로 메서드 오버라이드라고 함.
때로는 다형성 차원에서가 아닌 순수하게 독립적인 하나의 메서드로 이름을 정의하고 싶은 경우도 고려해야 한다. C# 에서는 같은 이름의 메서드를 일부러 겹쳐서 정의했단느 개발자의 의도를 명시적으로 표현할 수 있게 new 예약어를 제공한다.
- 메서드 오버라이드를 원하면 vitrual / override
- 단순히 자식 클래스에서 동일한 이름이 필요하면 new //new public void fnName(){}
#base를 이용한 메서드 재사용
public class Computer
{
virtual public void Boot()
{
Console.WriteLine("메인보드 켜기");
}
}
public class Notebook : Computer
{
override public void Boot()
{
base.Boot(); //Console.WriteLine("메인보드 켜기");를 한 번 더 쓰면
//'중복 코드 제거 원칙'에 위배되므로 base를 이용해 메소드 재사용
Console.WriteLine("액정 화면 켜기");
}
}
대개의 경우 부모 클래스의 기능을 완전히 재정의하고 싶다면 base 메서드 호출을 누락시키고, base 메서드의 기능 확장을 하려는 경우에는 base 메서드 호출과 함께 추가 코드를 작성하는 것이 일반적이다.
#object 기본 메서드 확장
#오버로드
메서드 시그니처(method signature)는 어떤 메서드를 고유하게 규정할 수 있는 정보를 의미한다. 메서드의 정의를 분리해 보면 '이름', '반환 타입', '매개변수의 수', '개별 매개변수 타입'으로 나뉘는데, 그것들이 바로 메서드의 '서명'이 된다. 따라서 '메서드가 같다'라는 말은 '메서드의 시그니처가 동일하다'라는 말로 해석할 수 있다.
'오버라이드'는 시그니처가 완전히 동일한 메서드를 재정의할 때 사용하는 것인 반면, '오버로드(overload)'는 시그니처 중에서 '반환값'은 무시하고 '이름'만 같은 메서드가 '매개변수의 수', '개별 매개변수 타입'만 다르게 재정의되는 경우를 말한다. 결국 오버라이드와 오버로드는 모두 '재정의'라는 한 단어로 번역된다.
++오버라이딩은 슈퍼클래스에서 정의된 메소드가 서브클래스에서 다시 정의되는 것
++오버로딩은 같은 클래스 안에 이름이 같은 여러 오퍼레이션을 정의하는 것
++다형성과 오버로딩 모두 수행될 오퍼레이션을 런타임에 선택하게 하는 방법이다
++다형성은 같은 이름의 오퍼레이션을 같은 클래스 안에 여러 번에 걸쳐 다르게 정의한다는 것이 차이점
#메서드 오버로드
다중 생성자; '매개변수의 수', '개별 매개변수 타입'만 다른 여러가지 생성자를 정의함으로써 오버로드라고 불린다.
#연산자 오버로드
연산자 역시 타입별로 재정의할 수 있다. 대표적인 사례로 +(더하기)가 있는데, 문자열 타입은 이어붙이고, 정수형 타입은 숫자값을 더한다.
public static 타입 operator 연산자 (타입1 변수명1, 타입2 변수명2)
{
//[타입]을 반환하는 코드
}
public static kilogram operator +(kilogram op1, kilogram op2) { return new kilogram(op1.mass + op2.mass);
C#에서는 연산자와 메서드 간의 구분이 없다. 원하는 연산자가 있다면 각 타입의 의미에 맞는 연산으로 새롭게 재정의하면 된다.
연산자에 따른 오버로드 가능 여부
C# 연산자 | 오버로드 가능 여부 |
+, -, !, ~, ++, --, true, false | 단항 연산자는 모두 오버로드 가능(+, -는 부호 연산자) |
+, -, *, /, %, &, |, ^, <<, >> | 이항 연산자는 모두 오버로드 가능(+, -는 사칙 연산자) |
==, !=, <, >, <=, >= | 비교 연산자는 모두 오버로드 할 수 있지만 반드시 쌍으로 재정의해야 한다. == 연산자를 오버로드 했다면 != 연산자도 해야 한다 |
&&, || | 논리 연산자는 오버로드할 수 없다. |
[] | 배열 인덱스 연산자 자체인 대괄호는 오버로드할 수 없지만 C#에서는 이를 대체하는 별도의 인덱서 구문을 지원한다. |
(type)x | 형 변환 연산자 자체인 괄호는 오버로드할 수 없지만 대신 explicit, implicit를 이용한 대체 정의가 가능하다. |
+=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= | 복합 대입 연산자 자체는 오버로드할 수 없지만 대입이 아닌 +, -, *, / 등의 연산자를 오버로드하면 복합 대입 연산 구문이 지원된다. |
기타 연산자 | 오버로드할 수 없다. |
#클래스 간의 형 변환
won, yen, dollar을 모두 decimal로 구현하면 잘못대입해서 오류가 날 수 있지만 class currency로 상속시켜서 타입을 다르게 구현하면 이런 위험성이 없어짐 이때 won과 yen 사이에 형 변환을 원한다면 =(대입 연사자)를 정의해야 하지만 이는 C#에서 허용하지 않는다. 이때 explicit, implicit 메서드를 정의하는 것으로 동일한 목적을 달성할 수 있다.
public class Currency
{
decimal money;
public decimal Money { get { return money; } }
public Currency(decimal money)
{
this.money = money;
}
}
public class Won : Currency
{
public Won(decimal money) : base(money) { }
public override string ToString()
{
return Money + "Won";
}
}
public class Yen : Currency
{
public Yen(decimal money) : base(money) { }
public override string ToString()
{
return Money + "Yen";
}
static public implicit operator Won(Yen yen)
{
return new Won(yen.Money * 13m);
} // Won won1 = yen implicit 암시적 형 변환 가능
// Won won2 = (Won)yen explicit 명시적 형 변환 가능
}
implicit -> 암시적, 명시적 둘 다 가능; explicit -> 명시적으로만 가능
# C#의 클래스 확장
- 타입 유형 확장: 중첩 클래스, 추상 클래스, 델리게이트, 인터페이스, 느슨한 결합, 구조체, 깊은 복사와 얕은 복사 ref & out 예약어, 열거형
- 멤버 유형 확장:
#중첩 클래스(nested class): 클래스 내부에 또 다른 클래스를 정의함
class의 경우 접근 제한자를 생략하면 기본적으로 internal이 되지만 중첩 클래스의 경우 접근 제한자가 생략되면 다른 멤버와 마찬가지로 private가 지정되어 외부에서 인스턴스를 직접 생성하는 것이 불가능해진다. -> 외부에서 사용하고 싶다면 명시적으로 public 접근 제한자 지정
클래스이름.중첩클래스이름 name = new 클래스이름.중첩클래스이름; //접근 제한 컴파일 오류
#추상 클래스(abstract class): 앞에서 설명한 메서드 오버라이드와 달리 부모 클래스의 인스턴스를 생성하지 못하게 하면서 특정 메서드에 대해 자식들이 반드시 재정의하도록 강제하고 싶을 때 사용한다.
# 추상 메서드(abstract method): abstract 예약어가 지정되고 구현 코드가 없는 메서드; 추상 클래스 안에서만 선언 가능; 추상 클래스는 반드시 자식 클래스에서 재정의해야 하기 때문에 접근 제한자로 private를 지정할 수 없다.
컴파일 단계에서부터 재정의를 강제하고 싶을 때 추상 클래스, 추상 메서드를 사용
#델리게이트(delegate): 메서드를 참조할 수 있는 타입
접근 제한자 delegate 대상_메서드의_반환타입 식별자( ······ 대상_메서드의_매개변수_목록 ······);
설명: 대상이 될 메서드의 반환 타입 및 매개변수 목록과 일치하는 델리게이트 타입을 정의한다. 참고로 C/C++ 개발자에게는 델리게이트를 간단하게 함수 포인터라고 설명한다.
관례적으로 delegate 이름에는 접미사 Delegate를 붙인다
- 메서드의 반환값으로 메서드를 사용할 수 있다
- 메서드의 인자로 메서드를 전달할 수 있다
- 클래스의 멤버로 메서드를 정의할 수 있다.
메서드가 프로그래밍 언어에서 이런 특성을 지닐 때 그것을 1급 함수(first-class-function)라 한다. 따라서 C#은 1급 함수가 지원되는 언어로, 이후 델리게이트의 특성을 좀 더 보강한 익명 함수, 람다 표현식이 제공된다
delegate 예약어 내부 닷벳 타입(MulticastDelegate)에 대한 '간편 표기법'
#콜백 메서드
콜백이란 역으로 피호출자(callee)에서 호출자(caller)의 메서드를 호출하는 것을 의미하고, 이때 역으로 호출된 '호출자 측의 메서드'를 '콜백 메서드'라고 한다.
//delegate와 object를 이용한 범용 정렬 코드
delegate bool CompareDelegate(object arg1, object arg2);
class SortObject
{
object[] things;
public SortObject(object[] things)
{
this.things = things;
}
public void Sort(CompareDelegate compareMethod)
{
object temp;
for(int i = 0; i < things.Length; i++)
{
int lowpPos = i;
for(int j = i + 1; j < things.Length; j++)
{
if (compareMethod(things[i], things[j]))
{
lowpPos = j;
}
}
temp = things[lowpPos];
things[lowpPos] = things[i];
things[i] = temp;
}
}
}
class Program
{
delegate void CalcDelegate(int x, int y);
static void Add(int x, int y) { Console.WriteLine(x + y); }
static void Subtract(int x, int y) { Console.WriteLine(x - y); }
static void Multiply(int x, int y) { Console.WriteLine(x * y); }
static void Divide(int x, int y) { Console.WriteLine(x / y); }
static void Main(string[] args)
{
CalcDelegate func = Add;
//메서드 보관 목록에 Sub, Mul, Div 더함
func += Subtract;
func += Multiply;
func += Divide;
func(10, 5);
//메서드 보관 목록에서 Mul 제거
func -= Multiply;
func(10, 5);
}
}
#인터페이스(interface)
인터페이스는 간단하게 계약(contaxt)이라고 정의되며, 구현 없이 메서드 선언만 포함된 클래스 문법과 비슷한 면이 있다
접근 제한자 interface 인터페이스_명
{
//[메서드 선언]
}
//인터페이스에는 메서드 선언을 0개 이상 포함할 수 있다. 관례적으로 인터페이스 이름에는 I 접두사를 붙인다.
인터페이스를 '추상 메서드만 0개 이상 담고 있는 추상 클래스'라고 생각해도 무방하다. 그러나 클래스는 다중 상속이 불가능하기 때문에 클래스가 아닌 interface라는 새로운 예약어를 만들어 다중 상속을 가능하게 한다.
class Computer { }
interface IMonitor //메서드 시그니처만을 포함하고 있는 인터페이스
{
void TurnOn();
int Inch
{
//인터페이스가 '메서드의 묶음'이고, C# 프로퍼티가 내부적으로는
//메서드로 구현되기 때문에 인터페이스에는 프로퍼티 역시 포함할 수 있다.
get { return 0; }
set { Inch = value; }
}
}
interface IKeyboard { }
class NoteBook : Computer, IMonitor, IKeyboard
{
//부모클래스의 메서드를 오버라이딩하면 접근 제한자를 생략시 부모클래스의 것을 따라가지만,
//interface의 메서드를 자식 클래스에서 구현할 경우 반드시 'public' 접근제한자를 명시해야함
public void TurnOn() { }
//void IMonitor.TurnOn() {} 처럼 작성시 public 접근 제한자 생략가능(private가 된건 아님)
//전자의 방식으로 구현시 TurnOn 메서드가 NoteBook 클래스의 멤버로 정의되지만 //notebook.TurnOn()로 호출
//후자의 경우는 명시적으로 인터페이스의 멤버에 종속시킨다고 표시하는 것과 같다 //형변환된notebook.Turnon()로 호출
}
#인터페이스의 사용 사례
- 상속으로서의 인터페이스: 인터페이스의 가장 기본적인 역할은 상속이다. 따라서 해당 인터페이스를 구현한 것과 상속받았다는 것은 같은 의미를 가진다. 비록 클래스 상속은 아니어서 구현 코드를 이어받은 것은 아니지만 적어도 메서드의 묶음에 대한 정의를 이어받은 것에 해당한다. 따라서 서로 다른 클래스라도 인터페이스만 공통으로 구현되어 있으면 해당 구현 클래스의 인스턴스에 대해 인터페이스로 접근하는 것이 가능하다.
- 인터페이스 자체로 의미 부여: 인터페이스에 메서드가 포함돼 있지 않은 상태, 즉 비어 있는 인터페이스를 상속받는 것으로도 의미가 부여될 수 있다.
- 인터페이스를 이용한 콜백 구현: 인터페이스에 포함된 메서드는 상속된 클래스에서 반드시 구현한다는 보장이 있다.
- IEnumerable 인터페이스: foreach 제어문은 배열과 컬렉션의 요소를 열거하긴 하지만, 더 정확하게 말하자면 foreach의 in 다음에 오는 객체가 IEnumerable 인터페이스를 구현하고 있다면 어떤 객체든 요소를 열거할 수 있다. string 타입도 IEnumerable 인터페이스를 구현한 사례 중 하나다.
- 느슨한 결합(loose coupling): 클래스 간에 구현 타입의 정보 없이 인터페이스 등의 방법을 이용해 상호 간에 맺은 계약만으로 동작하는 것을 말한다. (구현 타입의 정보를 명시하면 강력한 결합(tight coupling)); 인터페이스를 사용하면 기존 코드에 대한 수정 없이 인터페이스에 맞게 구현된 클래스라면 융통성 있게 받아들일 수 있음
#콜백 패턴에서 인터페이스 vs 델리게이트
다중 호출에 대한 필요성만 없다면 인터페이스를 이용해 콜백을 구현하는 것이 더 일반적이다.
- 인터페이스는 하나의 타입에서 여러 개의 메서드 계약을 담을 수 있다.
- 델리게이트는 '여러 개의 메서드'를 담을 수 있어서 한 번의 호출을 통해 다중으로 등록된 콜백 메서드를 호출할 수 있다는 고유의 장점이 있다.
//닷넷에 정의돼 있는 IEnumerable 인터페이스
namespace System.Collections;
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
//닷넷에 정의돼 있는 IEnumerator 인터페이스
namespace System.Collections;
public interface IEnumerator
{
object Current { get; } //현재 요소를 반환하도록 약속된 get 프로퍼티
bool MoveNext(); //다음 순서의 요소로 넘어가도록 약속된 메서드
void Reset(); //열거 순서를 처음으로 되돌릴 때 호출하면 되는 메서드
}
//IEnumerable를 구현하면 foreach 사용이 가능
class Hololive
{
}
class memberInfo
{
string name;
public memberInfo(string name) { this.name = name; }
public override string ToString() { return name; }
}
class HoloGroup : Hololive, IEnumerable
{
memberInfo[] HoloList = { new memberInfo("HosyoMarine"), new memberInfo("Ayame"),
new memberInfo("Noel"), new memberInfo("Suisei")};
public HoloGroup() { }
public IEnumerator GetEnumerator()
{
return new HoloNumerator(HoloList);
}
public class HoloNumerator : IEnumerator
{
int pos = -1;
int length = 0;
object[] HoloList;
public HoloNumerator(memberInfo[] HoloList)
{
this.HoloList = HoloList;
length = HoloList.Length;
}
public object Current
{
get { return HoloList[pos]; }
}
public bool MoveNext()
{
if(pos >= length -1)
{
return false;
}
pos++;
return true;
}
public void Reset()
{
pos = -1;
}
}
}
class Program
{
static void Main(string[] args)
{
HoloGroup holoGroup = new HoloGroup();
foreach(memberInfo member in holoGroup)
{
Console.WriteLine(member);
}
}
}
#구조체
값 형식에도 class처럼 사용자 정의 형식을 둠(클래스는 참조형, 구조체는 값 형식)
- 인스턴스 생성을 new로 해도 되고, 안 해도 된다.(new로 생성시 해당 변수의 모든 값을 0으로 할당)
- 기본 생성자는 명시적으로 정의할 수 없다.(C# 10부터 구조체에도 기본 생성자를 정의할 수 있다.)
- 매개변수를 갖는 생성자를 정의해도 마치 기본 생성자가 있는 것처럼 C# 컴파일러에 의해 자동으로 지원된다(클래스의 경우에는 포함되지 않는다.)
- 매개변수를 받는 생성자의 경우, 반드시 해당 코드 내에서 구조체의 모든 필드에 값을 할당해야 한다.
#깊은 복사와 얕은 복사
구조체는 인스턴스가 가진 메모리 자체가 복사되어 새로운 변수에 대입되는 것을 볼 수 있는데, 이를 다른 말로 깊은 복사(deep copy)라고 한다. 반면 참조 형식의 변수가 대입되는 방식을 일컬어 얕은 복사(shallow copy)라고 한다.
구조체 사용 -> 깊은 복사; 클래스 사용 -> 얕은 복사; 이를 선택하는 기준은 다음과 같다.
- 일반적으로 모든 사용자 정의 타입은 클래스로 구현한다.
- 깊은/얕은 복사의 차이가 민감한 타입은 선택적으로 구조체로 구현한다.
- 참조 형식은 나중에 배울 GC에 의해 관리받게 된다. 따라서 참조 형식을 사용하는 경우 GC에 부담이 되는데, 이런 부하를 피해야 하는 경우에는 구조체를 선택한다.
#ref 예약어(C# 7.0에서는 ref 예약어를 지역 변수와 반환값에도 적용할 수 있다.)
call by value -> 변수의 스택 값이 복사; CBV = PBV(Pass...)
call by reference -> 해당 변수의 스택 값을 담고 있는 주소가 복사; CBR = PBR
ref 예약어는 두 군데에서 사용 1)메서드의 매개변수를 선언할 때 함께 표기해야 하고 2) 해당 메서드를 호출하는 측에서도 명시해야 한다. //걍 포인터네
메서드에 ref 인자로 전달되는 변수는 호출하는 측에서 반드시 값을 할당해야만 함
#out 예약어
참조에 의한 호출을 가능하게 하는 또 하나의 예약어 out이 가지는 ref와의 차이점
- out으로 지정된 인자에 넘길 변수는 초기화되지 않아도 된다. 초기화돼 있더라도 out 인자를 받는 메서드에서는 그 값을 사용할 수 없다.
- out으로 지정된 인자를 받는 메서드는 반드시 변수에 값을 넣어서 반환해야 한다.
이와 유사한 용도로 닷넷에서는 각 기본 타입에 TryParse라는 메서드를 제공한다
//System.Int32 타입에 정의된 TryParse 정적 메서드
public static bool TryParse(string s, out int result);
(ToString() int 형 -> 문자형) <-> (TryParse 문자형 -> int 형)
ref는 메서드를 호출하는 측에서 변수의 값을 초기화함으로써 메서드 측에 의미 있는 값을 전달한다. 반면 out은 메서드 측에서 반드시 값을 할당해서 반환함으로써 메서드를 호출한 측에 의미 있는 값을 반환한다. ([IN, OUT] 특성을 가짐)
#열거형(enumeration type)
열거형도 값 형식의 하나로 byte, sbyte, short, ushort, int, uint, long, ulong만을 상속받아 정의할 수 있는 제한된 사용자 정의 타입이다. (0에서 시작)
[접근 제한자] enum 타입명
{
//숫자를 대표하는 식별자 이름 나열
}
//enum 타입은 숫자형 값에 사람이 인식하기 쉬운 문자열 이름을 부여한다.
//상속 타입을 지정하지 않는 경우 기본적으로 System.Int32가 된다.
enum은 ToString 메서드를 재정의했고, 그것의 내부 코드에서 숫자값보다는 문자열로 반환하는 역할을 한다.
System.Int32를 부모로 두기 때문에 당연히 Days 타입은 int를 비롯해 각종 숫자형 타입과 형 변환하는 것이 가능하고 그 반대도 마찬가지다. 제약이라면 암시적 형 변환이 아닌 명시적 형 변환을 해야한다는 것뿐이다.
enum을 2의 배수로 해서 OR 연산자로 사용해 정수형 값을 겹칠 수 있고, HasFlag 메서드를 사용해 특정 요소 값을 포함하고 있는지도 판단할 수 있다.
[Flags] 특성은 enum 타입에만 사용될 수 있다.
enum CalcType { Add, Minus, Multiply, Divide }
int Calc(CalcType opType, int operand1, int operand2)
{
switch (opType)
{
case CalcType.Add: return operand1 + operand2;
case CalcType.Minus: return operand1 - operand2;
case CalcType.Multiply: return operand1 * operand2;
case CalcType.Divide: return operand1 / operand2;
}
return 0;
}
프로퍼티 -> 메서드의 변형; 델리게이트 -> 중첩 클래스의 변형
#읽기 전용 필드
한 번만 값을 쓴 후 다시 값을 설정하지 못하게 만들고 싶다면 readonly 예약어를 사용해 읽기 전용 필드(field)를 정의하면 된다. (읽기 전용 필드는 생성자에서도 대입 가능; 일반 메서드에서 값 대입 불가)
기본적으로 모든 필드는 값이 변할 수 있다. 다른 말로 하면 객체의 '상태가 변할 수 있다'고 하는데, 이런 객체를 가변 객체(mutable object)라고 한다. 반면 객체의 상태가 한번 지정되면 다시 바뀔 수 없는 경우 이를 구분해서 특별히 불변 객체(immutable object)라고 한다.
#상수
상수(constant)를 간단하게 표현하면 리터럴에 식별자를 붙인 것이라고 할 수 있다. 뱐하는 값을 고정된 식별자로 가리키는 것이 변수라면 상수는 변하지 않는 값인 리터럴을 식별자로 재사용할 수 있게 만들어준다.
class 클래스_명
{
접근_제한자 const 상수타입 식별자 = 값;
}
상수는 readonly 변수와 유사하지만 몇 가지 점에서 분명한 차이가 있다.
- 상수는 static 예약어가 허용되지 않는다(의미상으로는 이미 static에 해당한다); 1)클래스 모든 인스턴스에 대해 동일한 값을 가지며, 2)인스턴스화 없이 접근 가능하고 3)변경이 불가능함
- 3.1절 '기본 자료형'에서 다룬 형식에 대해서만 상수 정의가 허용된다.
- 반드시 상수 정의와 함께 값을 대입해야 한다. 즉, 생성자에서 접근할 수 없다.
- 상수는 컴파일할 때 해당 소스코드에 값이 직접 치환되는 방식으로 구현된다.
기본 자료형의 숫자 형식은 그것들이 표현할 수 있는 수의 상한값과 하한값에 대해 MaxValue, MinValue라는 공통된 상수를 제공한다.
//개별적인 상수로 표현
const int Sunday = 0;
const int Monday = 1;
const int Tuesday = 2;
const int Wednesday = 3;
const int Thursday = 4;
const int Friday = 5;
const int Saturday = 5;
//상수를 enum 타입으로 묶어서 표현
enum Days
{
Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
}
#이벤트
델리게이트와 마찬가지로 이벤트(event)도 '간편 표기법'의 하나인데, 다음 조건을 만족하는 정형화된 콜백 패턴을 구현하려고 할 때 event 예약어를 사용하면 코드를 줄일 수 있다.
- 클래스에서 이벤트(콜백)을 제공한다.
- 외부에서 자유롭게 해당 이벤트(콜백)을 구독하거나 해지하는 것이 가능하다.
- 외부에서 구독/해지는 가능하지만, 이벤트 발생은 오직 내부에서만 가능하다.
- 이벤트(콜백)의 첫 번째 인자로는 이벤트를 발생시킨 타입의 인스턴스다.
- 이벤트(콜백)의 두 번째 인자로는 해당 이벤트에 속한 의미 있는 값이 제공된다.
이벤트는 델리게이트의 사용 패턴을 좀 더 일반화해서 제공하는 것으로 다음과 같이 간단하게 구문이 요약된다.
class 클래스_명
{
접근_제한자 event EventHandler 식별자;
}
//클래스의 멤버로 이벤트를 정의한다. 이벤트는 외부에서 구독/해지가 가능하고
//내부에서 이벤트를 발생시키면 외부에서 다중으로 이벤트에 대한 콜백이 발생할 수 있다.
이벤트는 그래픽 사용자 인터페이스(GUI:Graphic User Interface)를 제공하는 응용 프로그램에서 매우 일반적으로 사용된다. 예를 들어, 윈도우에 포함된 버튼이 있고, 버튼을 눌렀을 때 파일을 생성하는 작업을 한다고 가정해 보자. Button 클래스 제작자는 당연히 Click이라는 이벤트를 구현해 둘 것이고, 버튼을 이용하는 개발자는 Click 이벤트를 구독하는 메서드 내에서 파일 작업을 수행하는 코드를 작성하면 된다.
#인덱서
배열의 0번째 요소에 접근할 때 대괄호 연산자를 사용하는데, 배열이 아닌 일반 클래스에서 이런 구문을 사용하기 위해서(대괄호 연산자는 사용자가 정의할 수 없다.) C#은 this 예약어를 이용한 인덱서(indexer)라고 하는 특별한 구문을 제공한다.
Class 클래스_명
{
접근_제한자 반환타입 this[인덱스타입 인덱스식별자]
{
접근_제한자 get
{
//...[코드]...
return 반환타입과_일치하는_유형의_표현식;
}
접근_제한자 set
{
//인덱스식별자로 구분되는 값에 value를 대입
}
}
}
//인덱서를 이용하면 클래스의 인스턴스 변수에 배열처럼 접근하는 방식의 대괄호 연산자를 사용할 수 있다.
//프로퍼티를 정의하는 구문과 유사하며, 단지 프로퍼티명이 this 예약어로 대체된다는 점과
//인덱스로 별도의 타입을 지정할 수 있다는 점이 다르다.
구현하려는 클래스에 배열과 같은 식으로 접근할 필요가 있을 때 제공하는 것이 바람직함.
#정리
예약어 | using, namespace class, interface, struct, enum private, protected, public, internal return this, base typeof delegate, event virtual, override as, is sealed, abstract operator, implicit, explicit static, const, readonly ref, out |
문맥 예약어 | get, set, value |
문맥 예약어(contextual keywords)는 특정한 상황을 제외하고는 식별자로 쓰는 것이 가능하다. -> 하위 버전의 소스코드를 문제 없이 컴파일 하도록 도움
5장
# 문법 요소
#구문
#전처리기 지시문(preprocessor directive)
특정 소스코드를 상황에 따라 컴파일 과정에서 추가/제거하고 싶을 때 사용함.
Console.ReadLine(): 엔터키가 눌릴 때까지의 키보드 입력을 받는 역할을 하는 메서드
#if, #endif, #else, #elif, #define, #undef 전처리기 지시문
전처리기 상수(preprocessor constant)가 정의돼 있으면 컴파일
dotnet build 시 /p:DefineConstants 옵션을 통해 전처리 상수를 설정할 수 있다.
#define/#undef 문은 반드시 소스코드보다 먼저 나타나야 한다.
#warning, #error, #line, #region, #endregion, #pragma 지시문도 있음
#지역 변수가 정의되면 그것의 유효 범위는 변수를 포함하고 있는 블록과 일치한다; 부모 블럭은 자식 블록의 유효 범위를 포함함.
#코드 내에서 사용되는 리터럴도 그에 해당하는 타입이 적용된다. 예를 들어, 숫자 5는 int 형의 인스턴스이고 값이 고정된 변수처럼 사용될 수 있다. 즉, 숫자 5를 통해서도 System.Int32 타입의 멤버를 그대로 사용할 수 있다.
#특성(attribute)
주석을 통한 정보는 소스코드 파일에만 존재할 뿐, 컴파일러에 의해 빌드된 후 생성되는 EXE/DLL 파일에는 남지 않는 문제를 해결할 수 있다.
- 주석 - 사람이 읽고 쓰는 정보
- 애트리뷰트 - 사람이 작성하고 컴퓨터가 읽는 정보
닷넷의 어셈블리 파일에는 해당 어셈블리 스스로를 기술하는 메타데이터(어셈블리 내에서 구현하고 있는 타입, 그 타입 내에 구현된 멤버 등의 정보)가 포함돼 있다. 특성은 이런 메타데이터에 함께 포함되며, 원하는 데이터를 보관하는 특성을 자유롭게 정의해서 사용할 수 있다.
앞서 본 [Flags]은 특성이다. 특성 자체도 클래스이고 [Flags] 특성은 FlagsAttribute라는 클래스로서 마이크로소프트에서 미리 만들어 BCL에 포함해둔 것이다.
관례상 특성은 클래스 이름에 Attribute라는 접미사를 붙임
[AuthorAttribute]
class Dummy1 {}
[Author] //C#에서는 Attribute 접미사를 생략해도 됨
class Dummy1 {}
[Author()] //마치 new Author()처럼 생성자를 표현하는 듯한 구문도 사용할 수 있다.
class Dummy1 {}
//특성 클래스에 매개변수가 포함된 생성자를 추가할 수도 있다.
class AuthorAttribute : System.Attribute
{
string name;
int version;
public AuthorAttribute(string name)
{
this.name = name;
}
public int version
{
get { return _version; }
set { _version = value; }
}
#특성을 적용할 수 있는 대상 목록
AttributeTargets 값 | 의미 |
Assembly | 어셈블리가 대상인 특성 |
Module | 모듈이 대상인 특성 |
Class | class가 대상인 특성 |
struct | struct가 대상인 특성 |
Enum | enum가 대상인 특성 |
Constructor | 타입의 생성자가 대상인 특성 |
Method | 타입의 메서드가 대상인 특성 |
Property | 타입의 속성이 대상인 특성 |
Field | 타입의 필드가 대상인 특성 |
Event | 타입의 이벤트가 대상인 특성 |
Interface | interface가 대상인 특성 |
Parameter | 메서드의 매개변수가 대상인 특성 |
Delegate | delegate가 대상인 특성 |
ReturnValue | 메서드의 반환값에 지정되는 특성 |
GenericParameter | C# 2.0에 추가된 제네릭 매개변수에 지정되는 특성 |
All | AttributeTargets에 정의된 모든 대상을 포함 |
닷넷에서는 특성의 용도를 제한할 목적으로 System.AttributeUsageAttribute라는 또 다른 특성을 제공함.
메서드 내부의 코드를 제외한 C#의 모든 소스코드에 특성을 부여하는 것이 가능하고 특성을 정의할 때 AttributeUsage를 지정하지 않으면 기본값으로 AttributeTargets.All이 지정된 것과 같다.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
class AuthorAttribute : System.Attribute
{
// ... [생략] ...
}
#AttributeTargets와 대상 지정
AttributeTargets 값 | [target: ......] |
Assembly | assembly |
Module | module |
Class | class |
Struct | struct |
Enum | enum |
Constructor | method |
Method | method |
Property | property |
Field | field |
Event | event |
Interface | type |
Parameter | param |
Delegate | type |
ReturnValue | return |
GenericParameter | typevar |
일반적으로 대상을 생략하면 특성이 명시된 코드의 유형에 따라 표의 대상이 자동으로 선택된다. (BCL의 MarshalAs라는 특성은 적용 대상이 Field, Parameter, ReturnValue로 돼 있는데 이런 경우 명시해야 할 경우가 생긴다.)
[return: MarshalAs(UnmanagedType.I4)]
static int Main(string[] args)
{
return 0;
}
#다중 적용과 상속
AttributeUsage 특성에는 생성자로 입력받는 AttributeTargets 말고도 두 가지 속성이 더 제공됨
속성 타입 | 속성 이름 | 의미 |
bool | AllowMultiple | 대상에 동일한 특성이 다중으로 정의 가능(기본값: false) |
bool | Inherited | 특성이 지정된 대상을 상속받는 타입도 자동으로 부모의 특성을 물려받음, 일반적으로 잘 사용되지 않는다(기본값: true) |
[AttributeUsage(AttributeTargets.All, AllowMultiple = true)]
class AuthorAttribute : System.Attribute
{
//...[생략]...
}
//같은 특성이 아니라면 AllowMultiple 여부에 상관없이 대상 코드에 여러 개의 특성을 지정하는 것이 가능하다.
[Flags]
[Author("Anders")] //[Flags, Author(("Anders")] 처럼 쓰는 것도 가능하다
enum Days { /* ... [생략] ... */ }
#연산자
#시프트 연산자(>>, << c랑 같음)
- <<: 2 곱하는 효과
- >>: 2 나누는 효과
- 시프트는 하위 바이트의 숫자를 잘라내는 역할도 함
우측 시프트 연산에서 주의할 점은 최상위 비트(MSB: most significant bit)가 부호의 유무에 따라 처리 방식이 달라진다는 점이다. 부호 있는 정수는 최상위 비트가 쉬프트 연산을 해도 유지됨
#비트 논리 연산자(&, |, ^, ~ c랑 같음)
비트 논리 연산자가 사용되는 대표적인 경우는 각 비트의 값을 특정 상태를 나타내는 의미로 사용할 때다.(byte는 8개 상태 지정)
#연산자 우선순위
&& > ||
#예약어
#연산 범위 확인: checked, unchecked
C#은 음수를 2의 보수(complement)로 표현함
- 오버플로(overflow): 데이터가 상한값을 넘어 하한값으로, 반대로 하한값에서 상한값으로 넘어감
- 언더플로(underflow): 부동 소수점 연산에서 0에 가깝지만 정밀도의 한계로 표현할 수 없을 때 아예 0으로 만들어 버림; 닷넷에서는 언더플로에 대한 예외 처리는 제공하지 않는다.
개발자는 연산식에서 오버플로가 발생한 경우 C#으로 하여금 오류를 발생시키라고 명시할 수 있는데, 이때 checked 예약어가 사용된다.
short c = 32767;
checked
{
c++;
}
//Unhandled exception. System.OverflowException: arithmetic operation resulted in an overflow.
- C#은 컴파일러 수준에서 checked 상황을 전체 소스코드에 걸쳐 강제로 적용할 수 있는 '산술 오버플로 확인(Check for arithmetic overflow)' 옵션을 제공한다. //산술 오버플로 확인 - 정수 연산에서 범위를 벗어난 값을 생성하는 경우 예외를 Throw합니다.
이처럼 '산술 오버플로 확인' 옵션과 함께 컴파일된 경우, 반대로 특정 영역의 산술 연산에 대해서는 오버플로가 발생해도 오류를 내지 말라고 개발자가 unchecked 예약어를 지정할 수 있다.
#가변 매개변수: params
메서드를 정의할 때 몇 개의 인자를 받아야 할지 정할 수 없을 때가 있다. -> params 예약어를 사용해 가변 인자를 지정할 수 있다.
static int Add(params int[] array)
{
int result = 0;
for(int i = 0; i < array.Length; i++)
{
result += array[i];
}
return result;
}
++예약어 순서
[접근 제한자] [기타 수정자] [반환 타입] 메서드 이름 (매개변수)
기타 수정자끼리 위치 바뀌어도 컴파일러가 올바르게 인식함
#Win32 API 호출
닷넷 호환 언어로 만들어진 관리 코드(managed code)에서 C/C++ 같은 언어로 만들어진 비관리 코드(unmanaged code)의 기능을 사용하는 수단으로 플랫폼 호출(P/Invoke: platform invocation)이 있다. extern 예약어는 C#에서 PInvoke 호출을 정의하는 구문에 사용됨
extern 구문을 작성하려면 다음과 같은 세가지 정보가 필요하다
- 비관리 코드를 제공하는 DLL 이름
- 비관리 코드의 함수 이름
- 비관리 코드의 함수 형식(signature)
using System.Runtime.InteropServices;
class Program
{
[DllImport("user32.dll")]
static extern int MessageBeep(uint uType);
static void Main(string[] args)
{
MessageBeep(0);
}
}
//extern 예약어 자체는 메서드에 코드가 없어도 컴파일되게 하는 역할만 한다. 해당 Win32API와 C#코드를 연결하는
//역할은 DllImport 특성을 적용해야만 이용할 수 있다. 닷넷 CLR은 DllImport 특성으로 전달된 DLL 파일명에
//extern 예약어가 지정된 메서드와 시그니처가 동일한 Win32API를 연결한다. 이렇게 정의된 extern 정적 메서드를
//사용하는 방법은 일반적인 정적 메서드를 사용하는 방법과 동일하다.
C/C++에서 복잡한 자료형을 쓴다거나 포인터 구문을 사용하면 이를 C#의 자료형과 맞춰야 함
#안전하지 않은 컨텍스트: unsafe
C#은 C/C++ 언어의 포인터를 지원하며 unsafe 에약어는 포인터를 쓰는 코드를 포함하는 클래스나 그것의 멤버 또는 블록에 사용한다.
unsafe 예약어를 사용한 소스코드는 반드시 컴파일러 옵션으로 AllowUnsafeBlocks를 지정해야 한다.
dotnet build /p:AllowUnsafeBlocks=true
비주얼 스튜디오 환경이면 project property -> Build -> General 항목의 '안전하지 않은 코드(Unsafe code)' 옵션을 설정하면 csproj 프로젝트 파일에 그에 대한 속성이 정의된다; <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
#참조 형식의 멤버에 대한 포인터: fixed
unsafe 문맥에서 포인터는 스택에 데이터가 저장된 변수에 한해 사용할 수 있다. 즉, 지역 변수나 메서드의 매개변수 타입이 값 형식인 경우에만 포인터 연산자(*, &)를 사용할 수 있다.
반면 참조 형식의 데이터는 직접적인 포인터 연산을 지원할 수없다. 왜냐하면 참조 형식의 인스턴스는 힙에 할당되고 그 데이터는 가비지 수집기가 동작할 때마다 위치가 바뀔 수 있기 때문이다. -> fixed 예약어를 사용하면 힙에 할당된 참조 형식의 인스턴스를 가비지 수집기가 움직이지 못하도록 고정시킴으로써 포인터가 가리키는 메모리를 유효하게 만들 수 있다.
보통 fixed된 포인터는 관리 프로그램의 힙에 할당된 데이터를 관리되지 않은 프로그램에 넘기는 용도로 쓰인다.
#고정 크기 버퍼: fixed (pp.286 ~ 289)
C#의 경우 필드마다 배열이 별도의 메모리를 할당 받는다. 타입 내의 메모리 공간에 배열 공간을 품지 못하고 별도로 할당된 배열 공간에 참조 주소를 갖는 메모리 배치가 이뤄짐 -> fixed 배열을 사용하면 메모리 배열을 타입 내에 담을 수 있도록 지원함
#스택을 이용한 값 형식 배열: stackalloc
값 형식은 스택에 할당되고 참조 형식은 힙에 할당된다. 그런데 값 형식임에도 그것이 배열로 선언되면 힙에 할당된다. stackalloc 예약어는 값 형식의 배열을 힙이 아닌 스택에 할당하게 만든다.
int* pArray = stackalloc int[1024]; // int 4byte * 1024 == 4KB 용량을 스택에 할당
- 장점: 스택에 배열을 만드는 것으로 힙을 사용하지 않아 가비지 수집의 부하가 없어진다; 게임 프로그래밍을 할 때, 끊임없이 호출되는 메서드 내에서 힙에 메모리를 할당하면 가비지 수집기로 인해 끊김 현상이 발생할 수 있다. 이럴 때 stackalloc을 사용하면 가비지 수집기의 호출 빈도를 조금이라도 낮출 수 있어 좀 더 원활한 게임 실행이 가능해진다.
- 단점: 스택은 스레드마다 할당되는 메모리로 윈도우의 경우 (32 bit 프로세스 기준) 기본값으로 1MB 규모의 크기를 갖는다. 이처럼 제한된 자원을 남용하면 자칫 프로그램의 실행에 오류를 발생시킬 수 있으므로 사용할 때 신중을 기해야 한다.
#프로젝트 구성
프로젝트(project)는 비주얼 스튜디오의 소스코드 관리를 위해 도입된 개념이다. 한 프로젝트는 여러 개의 소스코드를 담을 수 있고, 해당 프로젝트를 빌드하면 하나의 EXE 또는 DLL 파일이 만들어진다.
프로젝트를 생성하면 그 프로젝트에서 관리하는 모든 정보를 담는 '프로젝트 파일'이 만들어짐 C#의 경우 'csproj'
프로젝트(csproj) 파일의 내용은 XML(eXtensible Markup Language) 형식을 따른다.
솔루션(solution)은 프로젝트보다 큰 단위이다. 여러 개의 프로젝트가 모여 하나의 솔루션을 구성한다.
관례적으로 보통 클래스 하나당 파일 하나를 만드는 것을 권장한다. 또한 유사한 기능으로 묶을 수 있는 파일은 폴더를 이용해 정리한다.
프로그래밍 언어에서 라이브러리(library)는 일반적으로 재사용 가능한 단위를 의미한다. 그리고 그것이 파일로 저장될 때는 확장자로 DLL이 붙는다.
닷넷 런타임이 설치되면 일부 라이브러리가 함께 컴퓨터에 설치되는데, 이것들을 가리켜 BCL(Base Class Library) 또는 FCL(Framework Class Library)이라고 한다.
class의 경우 접근 제한자를 생략하면 기본값이 internal인데, 같은 어셈블리(EXE 또는 DLL) 내에서만 그 기능을 사용할 수 있게 제한하는 역할을 한다.
DLL을 다른 프로그램에서 사용하는 것을 일반적으로 '참조(reference)한다'라고 표현한다.
.csproj 파일에 Reference 항목과 함께 HintPath를 이용해 DLL의 위치를 명시해야 한다.
(.NET Framework) 유형은 윈도우 운영체제에서만 실행 가능한 바이너리를 생성
#라이브러리 참조 방법: csproj 파일에 Reference로 기록되느냐 ProjectReference로 기록되느냐 차이
- DLL 파일의 위치를 직접 지정해 빌드에 포함하는 파일 참조
- 참조하려는 프로젝트와 참조되는 프로젝트가 같은 솔루션 내에 함께 있다면 사용할 수 있는 프로그램 참조 -> Dependencied 우클릭 -> Add Project Reference
#Nuget 패키지 참조
닷넷은 라이브러리 재사용을 높이기 위해 DLL 파일 및 기타 설명 파일을 추가한 패키지 규약을 만들었다. 이 패키지는 nuget 확장자를 가지며 좀 더 편리하게 재사용할 수 있도록 www.nuget.org 사이트가 운영되고 있다.
#디버그 빌드와 릴리스 빌드
프로그램을 만들다 보면 크게 두 가지 오류를 접할 수 있다.
- 컴파일 시 오류(Compile-time error): 문법 오류(syntax error)이며, 컴파일러의 오류 메시지 내용에 따라 올바른 문법으로 변경하면 해결한다.
- 실행 시 오류(Run-time error): 정상적으로 컴파일된 프로그램이지만 실행되는 시점에 오류가 발생하는 것으로 논리 오류(logical error) 등의 원인으로 발생한다.
컴파일러 제작자들은 개발자가 작성한 코드를 가능한 한 가장 빠른 속도 또는 가장 작은 용량의 프로그램으로 번역하는 최적화(optimization) 처리를 한다. 이런 식으로 최적화를 허용하는 빌드를 릴리스(Release) 빌드라 하고 그렇지 않은 경우를 디버그(Debug) 빌드라고 한다. 보통 디버그 빌드로 출력한 EXE/DLL은 디버깅을 위한 정보를 함께 포함하고 있기 때문에 프로그램을 개발하는 단계에서 주로 사용한다. 이후 프로그램이 완성돼 배포할 때가 되면 성능을 위해 릴리스 빌드를 사용한다.
[디버그 빌드]
C:\Users\ndhph> dotnet build
[릴리스 빌드]
C:\Users\ndhph> dotnet build -c Release
#DEBUG, TRACE 전처리 상수
빌드 옵션 | 전처리 상수 | 전처리 상수 |
빌드 옵션 | DEBUG | TRACE |
디버그 | O | O |
릴리스 | X | O |
TRACE 상수는 항상 정의되지만 DEBUG 상수는 오직 디버그 빌드에서만 정의됨
#if DEBUG ~ #endif 를 통해 디버그 빌드로 생성했을 때만 동작하는 코드를 만들 수 있음.
Conditional 특성으로도 #if DEBUG ~ #endif 같은 것을 구현 가능; [Conditional("DEBUG")]
#Debug 타입과 Trace 타입
using System.Diagnostics;
namespace ConsoleApp1;
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("사용자 화면 출력");
Debug.WriteLine("디버그 화면 출력 - Debug");
Trace.WriteLine("디버그 화면 출력 - Trace");
}
}
BCL의 하나인 System.Runtime.dll과 System.Diagnostics.TraceSource.dll에는 System.Diagnostics 네임스페이스 아래에 Debug와 Trace 타입이 각각 정의돼 있다. 이 타입들에는 대표적으로 WriteLine 메서드가 함께 제공되는데, 이것들은 모두 각 이름에 해당하는 DEBUG, TRACE 전처리 상수가 Conditional 특성으로 적용돼 있다.
주요 차이점
- 활성화 범위: DEBUG는 주로 디버그 빌드에서만 활성화되며, TRACE는 디버그 빌드와 릴리스 빌드 모두에서 활성화될 수 있습니다.
- 사용 목적: DEBUG는 주로 디버깅을 위한 코드를 포함하는 데 사용되며, TRACE는 로깅 및 추적을 위해 사용됩니다.
- 기본 정의: Visual Studio에서 디버그 빌드 시에는 DEBUG와 TRACE가 기본적으로 정의되지만, 릴리스 빌드 시에는 TRACE만 기본적으로 정의됩니다.
#예외(exception)
예외 타입을 만드는 관례상 기준
- 응용 프로그램 개발자가 정의하는 예외는 System.Exception을 상속받은 System.Application.Exception을 상속받는다.
- 접미사로 Exception을 클래스명에 추가한다.
- CLR에서 미리 정의된 예외는 System.SystemException을 상속받는다.
최근의 닷넷 가이드라인 문서에는 응용 프로그램 개발자가 만드는 예외를 Sysyem.Exception에서 직접 상속받도록 권장함
#System.Exception 타입의 주요 멤버
멤버 | 타입 | 설명 |
Message | 인스턴스 프로퍼티 | 예외를 설명하는 메시지를 반환한다. |
Source | 인스턴스 프로퍼티 | 예외를 발생시킨 응용 프로그램의 이름을 반환한다. |
StackTrace | 인스턴스 프로퍼티 | 예외가 발생된 메서드의 호출 스택을 반환한다. |
ToString | 인스턴스 메서드 | Message, StackTrace 내용을 포함하는 문자열을 반환한다. |
#예외 처리기(exception handler)
예외가 발생한 경우 CLR의 기본 처리 과정은 예외 메시지를 출력하고 프로그램을 종료하는 것이다. try/catch 문 사용하면 try {} 예외발생시 catch{}로 이동; 예외가 발생하면 원인이 되는 코드부터 try 블록의 마지막 코드까지는 실행되지 않음
예외처리기 중 catch 블럭은 catch { try {} catch{} }처럼 다중 블록을 허용한다. -> 다양한 유형의 예외가 try 블럭에서 발생할 수 있다면 catch 블록도 다중으로 구성할 수 있음
catch 블럭은 개발자가 원하는 예외만 잡을 수도 있다. catch(원하는예외){}
try{} catch{} finally{}
finally 예약어를 사용하면 try 블럭 내에서 예외가 발생하는 것과 상관없이 언제나 실행됨 ->finally 블록은 자원을 해제하는 코드를 넣어두는 용도로 적합하다.(try 블록에서 파일을 열었을 때, finally 블럭을 사용안하면 try, catch 블록 모두에 파일을 닫는 코드를 넣어야함)
그밖에 이런 경우도 고려해볼 수 있다.
//상속 관계를 고려해서 예외 타입을 지정해야함
int divisor = 0;
string txt = null;
try
{
Console.WriteLine(txt.ToUpper()); //System.NullReferenceException 발생
int quotient = 10 / divisor;
}
catch(System.Exception) //발생한 예외와 순서대로 비교하므로 상속 관계를 고려해 예외 타입을 지정해야 함
//System.Exception이 맨 위에 있으면 모든 예외가 System.Exception으로 형 변환
//가능하므로 다음의 catch 블록에 있는 코드는 결코 실행되지 않는다.
{
Console.WriteLine("예외가 발생하면 언제나 실행된다.");
}
catch (System.NullReferenceException)
{
Console.WriteLine("어떤 예외가 발생해도 실행되지 않는다.");
}
catch (System.DivideByZeroException)
{
Console.WriteLine("어떤 예외가 발생해도 실행되지 않는다.");
}
//catch 구문에는 예외 타입뿐만 아니라 예외의 인스턴스를 변수로 받는 것도 가능하다.
//이 변수를 이용하면 해당 예외 타입에서 제공되는 모든 멤버에 접근해서 정보를 가져올 수 있다.
int divisor = 0;
try
{
int quotient = 10 / divisor;
}
catch (System.DivideByZeroException e)
{
Console.WriteLine(e.Message);
Console.WriteLine(e.Source);
Console.WriteLine(e.StackTrace);
Console.WriteLine(e.ToString());
}
//위의 방법을 이용하면 프로그램이 실행되는 도중 발생하는 예외에 대한 기록을 남길 수 있다.
예외가 발생하면 예외 객체(e)가 자동으로 생성되고, 이 객체는 catch 블록에서 사용될 수 있다
#호출 스택
System.Exception 타입에는 StackTrace라는 string 타입의 멤버가 있다. 스택 트레이스(stack trace)는 자료구조의 일종인 스택에 저장된 데이터를 추적하는 것이다.
'스택 트레이스를 얻는다' = '(메서드의) 호출 스택을 얻는다'
#예외 발생
예외를 처리하는 것도 가능하지만 임의로 발생시키는 것 또한 가능하다; throw 예약어를 사용
class Program
{
public InvalidPasswordException(string msg): base(msg) { } //사용자 정의 예외 타입
static void Main(string[] args)
{
string txt = Console.ReadLine();
if(txt != "123")
{
InvalidPasswordException ex = new InvalidPasswordException("틀린 암호");
throw ex;
}
Console.WriteLine("올바른 암호");
}
}
catch 블록 내에 있는 throw는 예외 객체 없이 단독으로 사용할 수 도 있다.
try
{
//...
}
catch(System.Exception ex)
{
throw; //또는 throw ex;
//throw를 단독으로 사용한 경우 예외를 발생시킨 호출 스택이 모두 출력됨
//throw ex를 한 경우 실제 예외가 발생한 호출 스택은 없어지고 throw ex 코드가
//발생한 지점부터 호출 스택이 남음
}
#사용자 정의 예외 타입
예외는 타입이다. 따라서 원한다면 별도로 클래스를 만들어 사용할 수 있다. 사용자 정의 예외는 System.Exception을 부모로 두는 것을 권장함.
사용자 정의 예외 타입은 규모가 큰 프로젝트에서 내부 규정에 의해 체계적인 예외를 강제화하는 상황에서나 겨우 사용되는 정도다.
#올바른 예외 처리
프로그램 오동작을 방지 -> 예외 발생 vs 결과를 알리기
예외를 발생시킬 때 호출 스택의 상위 메서드에서 try/catch를 수행하지 않고 있다면 프로그램이 비정상적으로 종료된다는 위험이 있음.
if return 방식으로 처리하면 예외와 달리 강제성이 없어서 무심코 잘못 사용할 수 있다. 그렇다고 예외를 무작정 남발하면, 습관적인 예외 처리가 낳는 부정적인 결과로는 '예외를 먹는(swallowing exceptions)' 상황이 있다.이 표현은 프로그램에 분명 문제가 발생했는데, 예외 처리로 인해 외부에 아무런 문제 현상이 나타나지 않는 것을 의미한다. 예외 처리를 이렇게 해버리면 결국 오류를 나타내는 반환값을 무시하는 방식과 다를 게 없다. 따라서 try/catch는 스레드 단위마다 단 한 번만 전역적으로 적용해야 한다.
그 밖의 코드에서 예외 처리가 필요하다면 try/catch를 하더라도 catch에 정확한 예외 타입을 지정하는 것을 원칙으로 한다. 그리고 자원 수거가 목적인 try/finally 절은 자유롭게 사용할 수 있다.
예외가 발생한 경우의 처리가 매우 무겁다. 예외 처리를 할 때 CLR 입장에서는 실행해야 할 내부 코드가 늘어나기 때문에 처리 시간이 그만큼 늘어난다.
#예외 처리를 할 때 지키면 좋은 규칙
- 적어도 공용(public) 메서드에 한해서는 인자값이 올바른지 확인하고, 올바른 인자가 아니라면 예외를 발생시킨다.
- 예외를 범용적으로 catch하는 것은 스레드마다 하나만 둔다. 그 외에는 catch 구문에 반드시 예외 타입을 적용한다.
- try/finally의 조합은 언제든 사용할 수 있다.
- 성능상 문제가 발생할 수 있는 경우, 즉 호출 시 예외가 대량으로 발생하는 메서드가 있다면 예외 처리가 없는 메서드를 함께 제공한다.
try
{
// 특정 예외 타입을 catch
DoSomething();
}
catch (ArgumentException e)
{
// 인자 예외 처리
Console.WriteLine($"Argument error: {e.Message}");
}
catch (InvalidOperationException e)
{
// 잘못된 작업 예외 처리
Console.WriteLine($"Operation error: {e.Message}");
}
catch (Exception e)
{
// 범용 예외 처리 (스레드마다 하나만)
Console.WriteLine($"Unexpected error: {e.Message}");
// 추가 로깅 또는 정리 작업
}
#힙과 스택
#스택(stack)
스택은 스레드가 생성되면 기본적으로 1MB(닷넷의 경우 사용자 정의 가능)의 용량으로 스레드마다 할당되고, 이름에서 알 수 있듯이 자료구조에서 다루는 스택과 동작 방식이 같다.
스택은 메서드 호출이 깊어질수록 그와 함께 스택 사용량도 늘어난다. (호출한 메서드가 또 다른 메서드를 호출하면 호출자 메서드는 스택을 점유한 상태이기 때문에 계속 쌓임) 이렇게 메서드 콜 스택이 많이 쌓여서 1MB 용량을 넘는다면 '스택 오버플로(stack overflow)가 발생했다고 표현한다. 재귀호출의 경우 이런 문제 발생할 수 있다.
스택 오버플로 예외가 발생했을 때 소스코드의 라인 정보가 출력되지 않는데 이미 스택 메모리가 모두 소비됐기 때문에 그 상황에서 오류 상황을 알리는 메서드를 호출할 수 없기 때문임
#힙(heap)
힙의 경우 별도로 명시하지 않는 한 CLR에서는 관리 힙(managed heap)을 가리킨다. 관리 힙이란 CLR의 가비지 수집기(GC: Garbage Collector)가 할당/해제를 관리하기 때문에 붙여진 이름이다. C#에서 new로 할당되는 모든 참조형 객체는 힙에 할당된다.
C#에서는 new로 할당된 메모리를 직접 해제하는 명령어는 없다. 왜냐하면 해제는 GC가 자동으로 해주기 때문이다. 네이티브 응용 프로그램에서는 개발자가 주의 깊게 할당과 해제 쌍을 맞춰야 한다. 할당만 하고 해제를 잊어버리면 점차 해제되지 않은 메모리가 누적되어 나중에는 메모리 부족 현상이 발생할 수 있다. 이를 메모리 누수 현상(memory leak)이라 한다.
GC의 동작은 프로그램의 다른 동작을 중지시킨다는 것을 염두에 둬야 한다. 즉, 힙을 많이 사용할수록 GC는 더 자주 동작하고 그만큼 프로그램은 빈번하게 실행이 중지되어 심각한 성능 문제를 겪을 수 있다.
#박싱/언박싱(boxing/unboxing)
값 형식을 참조 형식으로 변환하는 것을 박싱이라고 하며, 그 반대를 언박싱이라고 한다. object 타입과 System.ValueType을 상속받은 값 형식의 인스턴스를 섞어 쓰는 경우에 발생함
class Program
{
static void Main(string[] args)
{
//지역 변수이므로 스택 메모리에 5라는 값이 들어감
int a = 5;
//obj는 지역 변수이므로 스택 메모리에 할당됨 하지만 object는 참조형이기 때문에
//힙에도 메모리가 할당되고 변수 a의 값이 들어감(박싱)
//obj 지역 변수는 힙에 할당된 주소를 가리킴
object obj = a;
//b는 지역 변수이므로 스택 메모리에 b 영역이 있고, 힙 메모리에 있는 값을
//스택 메모리로 복사함(언박싱)
int b = (int)obj;
}
}
위 소스코드에서 보다시피 값 형식을 object로 형 변환하는 것은 힙에 메모리를 할당하는 작업을 동반한다. 이와 유사한 경우가 메서드에 인자를 전달할 때 발생한다.
박싱이 빈번할수록 GC는 바빠지고 프로그램의 수행 성능은 그만큼 떨어진다. Console.WriteLine 메서드에서 다양한 타입의 매개변수를 받도록 정의된 것은 이를 방지하기 위한 일례임
#가비지 수집기(GC: Garbage Collector)
CLR의 힙은 세대(generation)로 나누어 관리된다. 처음 new로 할당된 객체는 0세대(generation 0)에 속한다.
GC.Collect 메서드를 이용해 가비지 수집을 강제로 발생
지역 변수는 명시적으로 null을 대입하지 않는 한 메서드가 끝날 때까지는 유효하므로 메서드가 반환될 때까지는 살아 있게 한다.
#pp.341~344
관리 힙의 입장에서 세대를 구분하는 것은 단지 메모리의 위치를 가리키는 내부 포인터에 의해 이뤄진다. 일단 프로그램이 실행되면 GC는 관리 힙을 하나 만든다.
0세대부터 시작해서 객체가 힙에 생성되다가 가비지 수집이 발생하면 기존에 있던 객체들은 모두 1세대씩 승격하고(승격 기준은 포인터가 가리키는 위치) 이후 새로 생성되는 객체는 다시 0세대;
가비지 수집이 발생하면 기존 객체의 주소가 바뀐다. 바뀐 주솟값은 이들을 참조하는 스택 변수에 그대로 반영된다.
힙 객체를 참조하는 스택 변수, 레지스터, 또 다른 힙 객체를 루트 참조(root reference)라고 한다. 가비지 수집에서 살아남을 수 있는 객체란 다른 말로 루트 참조가 있는 것을 의미한다. 루트 참조가 사라지면 다음번 GC에서 해당 객체는 제거된다.
#전체 가비지 수집(Full GC)
GC가 세대를 구분한 이유는 프로그램 실행 도중 0세대에 할당되고 수집되는 비율이 매우 높다는 통계적인 근거를 기반으로 한다. 따라서 0세대 객체가 꾸준히 할당되어 가비지 수집이 될 기준을 넘어서면 GC는 모든 세대에 걸쳐 가비지 수집을 하지 않고 우선 0세대 힙에 대해서만 빠르게 수행한다. 0세대 가비지 수집만으로 메모리 확보가 부족해지면 1세대 힙까지 하고 이런 현상이 계속되면 전체 세대에 걸쳐 가비지 수집을 하는 경우도 발생한다.
메서드 | 인자 | 수집 대상 |
GC.Collect(int generation) | 0 | 0세대 힙만을 가비지 수집 |
`` | 1 | 0과 1세대 힙만을 가비지 수집 |
`` | 2 | 0, 1, 2세대 전체에 걸쳐 가비지 수집 |
마이크로소프트는 개발자가 GC.Collect 메서드를 명시적으로 호출해 가비지 수집하는 것을 권장하지 않는다. 하지만 가끔 많은 메모리 공간을 차지하는 객체를 생성한 경우 그것을 강제로 가비지 수집하는 목적으로 사용되기도 한다.
#대용량 객체 힙
가비지 수집으로 살아남은 객체는 이동함(포인터 이동) 이런 식의 가비지 수집은 대용량 객체에게는 부담이 됨. 이 때문에 CLR은 일정 크기 이상의 객체는 별도로 대용량 객체 힙(LOH: Larger Object Heap)이라는 특별한 힙에 생성한다.(객체의 크기가 85,000바이트(~=85kb) 이상인 경우 LOH에 할당된다. 이 크기는 내부적으로 정의된 것이므로 마이크로소프트에 의해 언제든 바뀔 수 있다.)
LOH에 할당된 객체는 가비지 수집이 발생해도 메모리 주소가 바뀌지 않는다. 이 때문에 LOH에 객체를 생성/해제하다 보면 필연적으로 메모리 파편화(fragmetation) 현상이 발생한다. -> 실제로는 여유 공간이 있어도 연속적으로 할당할 수 있는 공간이 없어서 메모리 부족 오류가 발생할 수 있다
#자원 해제
GC가 언제 동작할지는 CLR 내부에 의해 결정된다. 경우에 따라서는 관리 힙에 객채들이 자주 생성되지 않는다면 오랜 시간 동안 객체가 소멸되지 않을 수도 있다.
자원 해제와 관련해서 흔한 예는 파일을 처리하는 것과 관련이 있다. 닷넷에서 파일은 FileStream 객체를 통해 조작할 수 있다.
FileStream 객체를 생성하고 나온 파일을 윈도우 탐색기를 통해 지우려하면 지워지지 않음 -> FileStream 객체가 관리 힙에 남아 있는 상태이고 그 파일을 독점적으로 소유하고 있어 잠겨 있기 때문임 -> GC만 믿고 자원 해제를 소홀히 하는 것은 프로그램 운영에 장애를 가져올 수 있음; 자원 해제 필요
마이크로소프트는 자원 해제가 필요하다고 판단되는 모든 객체는 개발자로 하여금 IDisposable 인터페이슬 상속받도록 권장하고 있다.
public interface IDisposable
{
void Dispose(); //IDisposable 인터페이스에 정의된 메서드는 단 하나임
}
인스턴스를 Dispose하기 전에 예외가 발생한다면 Dispose 메서드가 호출되지 않으므로 정상적으로 자원 회수가 안된다. 보통 try/finally를 이용해 Dispose를 호출하는 것이 관례인데 C#은 부가적으로 try/finally를 대신하는 using 예약어를 제공한다.(여기서의 using은 네임스페이스를 선언하는 using과 이름만 같을 뿐 전혀 다른 역할을 한다.)
FileLogger log = null;
try
{
log = new FileLogger("sample.log");
log.Write("foobar, foo, bar, baz, qux, quux");
}
finally
{
log.Dispose();
}
//위와 똑같이 번역됨
using(FileLogger log = new FileLogger("sample.log"))
{
log.Write("foobar, foo, bar, baz, qux, quux");
}
#종료자(finalizer) 객체가 관리 힙에서 제거될 때 호출되는 메서드
class UnmanagedMemoryManager
{
~UnmanagedMemoryManager() // finalizer
{
Console.WriteLine("executed"); // 관리 힙에서 제거될 때 수행
}
}
관리 힙에 할당된 객체의 루트 참조가 없어지면 언젠가는 GC의 실행으로 메모리가 반드시 해제된다. 이것은 '관리 힙'인 경우에 한하고, '비관리 메모리'에 할당되는 메모리 자원, 또는 윈도우 운영체제와 연동되는 핸들(HANDLE)과 같은 자원은 GC의 관리 범위를 벗어나므로 개발자가 직접 해제를 담당해야 한다.
using System.Diagnostics;
using System.Runtime.InteropServices;
class Program
{
static void Main(string[] args)
{
while (true)
{
UnmanagedMemoryManager m = new UnmanagedMemoryManager();
m = null;
//GC를 강제로 수행
GC.Collect();
//현재 프로세스가 사용하는 메모리 크기 출력
Console.WriteLine(Process.GetCurrentProcess().PrivateMemorySize64);
}
}
}
class UnmanagedMemoryManager
{
IntPtr pBuffer;
~UnmanagedMemoryManager()
{
//AllocCoTaskMem 메서드는 비관리 메모리를 할당한다
//AllocCoTaskMem의 도움을 받으면 .NET에서도 4GB 이상 배열을 다룰 수 있다.
pBuffer = Marshal.AllocCoTaskMem(4096 * 1024); //의도적으로 4MB 할당
}
}
UnmanagedMemoryManager 클래스는 생성자에서 4MB 크기의 비관리 메모리를 할당한다. 이렇게 할당된 메모리는 GC의 관리 힙에 위치하지 않기 때문에 GC.Collect를 호출하더라도 수거되지 않는다. 위 코드를 실행하면 32비트 프로세스의 사용 가능한 2GB 메모리 용량을 모두 소진해서 OutOfMemoryException 예외가 발생한다.
using System.Diagnostics;
using System.Runtime.InteropServices;
class Program
{
static void Main(string[] args)
{
while (true)
{
using (UnmanagedMemoryManager m = new UnmanagedMemoryManager())
{
}
Console.WriteLine(Process.GetCurrentProcess().PrivateMemorySize64);
}
}
}
class UnmanagedMemoryManager : IDisposable
{
IntPtr pBuffer;
public UnmanagedMemoryManager()
{
//AllocCoTaskMem 메서드는 비관리 메모리를 할당한다
pBuffer = Marshal.AllocCoTaskMem(4096 * 1024); //의도적으로 4MB 할당
}
public void Dispose()
{
Marshal.FreeCoTaskMem(pBuffer);
}
}
좀더 안정적인 클래스 구현을 위해 종료자를 사용한 코드는 다음과 같다; 개발자가 Dispose를 호출하지 않았음에도 클래스에 포함된 종료자 덕분에 메모리 부족 현상을 겪지 않는다.
using System.Diagnostics;
using System.Runtime.InteropServices;
class Program
{
static void Main(string[] args)
{
while (true)
{
UnmanagedMemoryManager m = new UnmanagedMemoryManager();
m = null;
GC.Collect(); //GC로 인해 종료자가 호출되므로 비관리 메모리도 해제됨.
Console.WriteLine(Process.GetCurrentProcess().PrivateMemorySize64);
}
}
}
class UnmanagedMemoryManager : IDisposable
{
IntPtr pBuffer;
bool _disposed;
public UnmanagedMemoryManager()
{
//AllocCoTaskMem 메서드는 비관리 메모리를 할당한다
pBuffer = Marshal.AllocCoTaskMem(4096 * 1024); //의도적으로 4MB 할당
}
public void Dispose()
{
if(_disposed == false)
{
Marshal.FreeCoTaskMem(pBuffer);
_disposed = true;
}
}
~UnmanagedMemoryManager() //종료자: 가비지 수집이 되면 호출된다.
{
Dispose();
}
}
종료자는 GC가 동작한다면 호출되는 것이 보장된다. 이 때문에 개발자가 Dispose 메서드의 호출 코드를 잊어버렸더라도 GC가 발생할 때까지 시간은 걸리겠지만 종료자에 정의된 자원 해제 코드가 언젠가 실행되므로 메모리 누수 현상이 사라진다. 즉, 클래스를 만든 개발자가 해당 클래스를 사용하는 개발자의 실수를 예상하고 방어적인 차원에서 자원 해제 코드를 넣어 두는 곳이 종료자다.
루트 참조는 힙에 있는 객체를 참조하는 주소입니다.
- 종료자를 구현한 객체 pg가 new로 할당되는 경우: CLR은 pg 객체를 관리 힙에 생성하는 것과 특별히 종료 큐(finalization queue)라는 내부 자료구조에 객체를 함께 등록함
- GC가 한번 수행된 후의 종료자를 가진 객체 상황: 종료 큐의 루트 참조로 인해 객체 pg 정리되지 못하고 살아남아 GC 세대가 +1 된다. 종료 큐에 있던 pg 객체의 참조를 제거하고 별도의 Freachable 큐에 또 다시 객체를 보관해 둔다.
- 종료자가 실행된 후 객체 pg의 상황: Freachable(Finally reachable) 큐에 들어온 객체를 CLR에 의해 미리 생성해 둔 스레드가 꺼내고 그것의 종료자 메서드를 호출해 준다. 이 스레드는 Freachable 큐에 항목이 들어올 때마다 해당 객체를 꺼내서 종료자를 실행하는 역할만 담당하는 특수한 목적의 스레드다.
- 이후 Freachable 큐는 다시 비어있는 상태로 바뀌고 비로소 종료자를 가졌던 객체는 종료자를 가지지 않았던 일반 객체와 같은 상황으로 바뀐다. 이후 GC가 한번 더 동작하면 pg는 관리 힙에서 제거된다.
한마디로
1.종료자가 구현된 객체는 종료 큐에 객체를 등록함
2.GC가 동작할 때 종료 큐에 등록된 객체는 바로 정리하지 않음 이후 종료 큐에 있는 객체를 Freachable로 옮김
3.Freachable 큐로 옮겨진 객체는 전용 스레드가 실행하고 Freachble에서 제거 이후 객체는 일반 객체와 같아짐
위와 같은 이유로 종료자가 구현된 클래스는 GC에게 더 많은 일을 시킴 따라서 특별한 이유가 없다면 종료자를 추가하지 않는 것을 권장함
종료자는 개발자가 Dispose 메서드를 명시적으로 호출했다면 굳이 (GC에게 많은 일을 시키면서까지) 호출될 필요가 없다. 마이크로소프트에서는 이처럼 명시적인 자원 해제가 됐다면 종료 큐에서 객체를 제거하는 GC.SuppressFinalize 메서드를 제공한다.(종료큐에서 바로 제거해서 Freachable로 옮기고 실행하는 과정을 안거쳐도 됨)
class UnmanagedMemoryManager : IDisposable
{
//...[생략]...
void Dispose(bool disposing)
{
if (_disposed == false)
{
Marshal.FreeCoTaskMem(pBuffer);
_disposed = true;
}
if (disposing == false)
{
//disposing이 false인 경우란 명시적으로 Dispose()를 호출한 경우다.
//따라서 종료 큐에서 자신을 제거해 GC의 부담을 줄인다.
GC.SuppressFinalize(this);
}
}
void Dispose()
{
Dispose(false);
}
~UnmanagedMemoryManager() //종료자: 가비지 수집이 되면 호출된다.
{
Dispose(true);
}
}
c# 1.0정리
전처리기 지시문 | #if / #else / #elif / #endif #define #undef |
연산자 | 시프트 연산자: <<, >> 비트 논리 연산자: &, |, ^, ~ 포인터 연산자:&, * |
예약어 | checked, unchecked (오버플로) params (매개변수 여러개) extern, uinsafe, fixed stackalloc internal try, catch, throw, finally using |
+그밖의 GC 관해서...
6장
#BCL(Base Class Library)
BCL은 Console 타입 외에도 닷넷 응용 프로그램과 운영체제 사이를 중계하는 다양한 클래스를 미리 만들어 제공하고 있다. 즉, 운영체제의 소켓(Socket), 스레드(Thread), 파일(File), 레지스트리(Registry) 등에 접근하고 싶다면 BCL에서 제공하는 클래스를 사용하면 된다. 운영체제와의 중계 역할 외에도 프로그램의 '처리'에 해당하는 과정에서 해당하는 과정에서 자주 사용되는 것을 함께 포함시키기도 한다.(Math)
#시간
//System.DataTime
public DateTime(int, year, int month, int day, int hour, int minute, int second, int millisecond);
Ticks 속성; 1밀리초의 10,000분의 1에 해당하는 정밀도를 보임
#시간대가 반영된 것을 지역 시간(local time)이라 한다. 영국의 경우 UTC(Universal Time, Coordinated)와 동일하지만 그 외 거의 모든 나라에서는 시간을 나타낼 때 UTC인지 지역 시간인지 명시해야 정확한 시간을 알 수 있다. 닷넷의 DateTime 타입은 이 구분을 열거형 타입인 Kind 속성으로 알려준다.
#DateTimeKind
열거형 값 | 설명 |
Unspecified | 어떤 형식인지 지정되지 않은 시간 |
Utc | 동시간의 그리니치 천문대 시간 |
Local | 시간대를 반영한 지역시간 |
#닷넷의 DateTime은 시간의 기준값이 1년 1월 1일이지만, 유닉스 및 자바 관련 플랫폼에서는 1970년 1월 1일을 기준으로 한다. 그 시간을 가리켜 Epoch Time이라 하고 다른 말로는 Unix Time, Unix Timestamp, POSIX time이라고도 한다.
#System.TimeSpan
DateTime 타입에 대해 사칙 연산 중에서 유일하게 허용되는 것이 '빼기'다. 그리고 빼기의 연산 결괏값은 2개의 DateTime 사이의 시간 간격을 나타내는 TimeSpan으로 나온다.
DateTime EndofYear = new DateTime(DateTime.Now.Year, 12, 31);
DateTime now = DateTime.Now;
TimeSpan gap = EndofYear - now;
Console.WriteLine("올해의 남은 날짜: " + gap.ToString());
#System.Diagnostics.Stopwatch
시간차에 대해 DateTime과 TimeSpan을 쓰는 것도 가능하지만, 닷넷에서는 더 정확한 시간차 계산을 위해 Stopwatch 타입을 제공한다. 코드의 특정 구간에 대한 성능을 측정할 때 자주 사용됨
static void Main(string[] args)
{
Stopwatch sw = new Stopwatch();
sw.Start();
Sum();
sw.Stop();
//Stopwatch.Frequency 속성이 초당 흐른 틱수를 반환함
Console.WriteLine("Sum()의 실행시간: " + sw.ElapsedTicks / Stopwatch.Frequency + 's');
}
#문자열 처리
#System.String
문자열 처리는 대부분 string 타입에서 제공됨
++ txt.replace( , )
#EndsWith, IndexOf, StartsWith 메서드에 StringComparison 열거형 인자를 추가 가능; StringComparison.OrdinalIgnoreCase하면 대소문자 구분을 안함
#'=='는 대소문자 무시 기능은 없지만 Equals 메서드로 바꾸면 대소문자를 무시한 연산이 가능하다.
#Format 메서드(C# 6.0에 개선된 문법 추가) //printf 비슷
인자를 형식 문자열에 포함된 번호와 맞춰서 치환하는 기능
string txt = "Hello {0}: {1}";
string output = String.Format(txt, "World", "Anderson");
Console.WriteLine(output);
Console.WriteLine(txt, "World", "Anderson");
{번호[,정렬][:형식문자열]} ex) {0, -10:N}
번호(필수): 지금까지의 예에서 본 것처럼 숫자 0부터 시작하는 번호를 지정
정렬(선택): 번호와 대응되는 문자열의 최소 너비를 지정, 대응 문자열의 길이는 5인데 정렬로 지정된 숫자가 10이라면 나머지 너비는 공백으로 채워짐. 정렬값이 음수이면 왼쪽, 양수이면 우측 정렬이 된다. 정렬값이 생략되면 대응되는 문자열의 길이대로 출력됨
형식문자열(선택): 대응되는 인자의 타입에서 직접 구현하는 형식 문자열이 사용됨. 따라서 Int32, Double 등의 타입에 따라 그에 맞는 형식 문자열을 찾아서 지정.
#System.Text.StringBuilder
string 타입은 불변 객체(immutable object)이기 때문에 string에 대한 모든 변환은 새로운 메모리 할당을 발생시킨다.
string txt = "Hello World";
string lwrText = txt.ToLower();
txt 원문이 통째로 복사된 다음 그것이 소문자로 변경되어 반환되는 절차를 거침
힙에 문자열을 담은 공간을 할당 스택에잇는 변수에 할당된 힙의 주소를 저장; 할당 저장 복사
string 타입 연산을 빠르게 하기 위한 클래스 StringBuilder -> 미리 일정한 양의 메모리를 할당하고 부족해지면 추가로 할당하는 방식이라서 메모리 할당 복사 빈도를 줄여서 성능 향상; 문자열을 연결하는 작업이 많을 때는 반드시 StringBuilder를 사용하는 것을 권장한다.
string txt = "Hello World";
StringBuilder sb = new StringBuilder();
for(int i = 0; i< 3000000; i++)
{
sb.Append("1");
}
string newText = sb.ToString();
#System.Text.Encoding
'A', 'B', 'C'라는 문자는 시스템에 내장된 폰트를 기반으로 출력된 일종의 '그림'에 불과하다. 내부적으로 이런 문자는 숫자에 대응된다. 이처럼 문자가 숫자로 표현되는 것을 인코딩(encoding: 부호화)이라 한다.
유니코드 == UTF-16
Encoding.UTF8.GetBytes(txtData) <-> Encoding.UTF8.GetString(received)
#System.Text.RegularExpressions.Regex
정규 표현식(regular expression)은 문자열 처리에 대한 일반적인 규칙을 표현하는 형식 언어다. 즉, 그 자체가 하나의 언어로서 다뤄질 수 있다.
using System.Text.RegularExpression;
Regex regex = new Regex(정규표현식);
//다음의 메서드도 제공
//regex.IsMatch, regex.Replace(txt,funcMatch)
#직렬화/역직렬화
프로그램에서 다뤄지는 모든 데이터는 엄밀히 말하면 byte 데이터다. 파일에 저장되거나 네트워크 선을 타고 이동하는 단위는 byte 데이터다.
#좁은 의미에서 볼 때 일련의 바이트 배열로 변환하는 그 작업을 가리켜 직렬화(serialization)라고 하고, 그 바이트로부터 원래의 데이터를 복원하는 작업을 역직렬화(deserialization)라고 한다.
바이트 배열은 직렬화 수단에 불과하다. 데이터를 어떤 것에 보관하고, 그것으로부터 복원만 할 수 있다면 그 모든 작업을 넓은 의미에서 직렬화/역직렬화라고 정의할 수 있다.
#System.BitConverter
문자열은 인코딩 방식에 따라 같은 문자열이라도 바이트 배열로의 변환이 달라질 수 있다. 그밖의 기본 타입(byte, short, int, ......)은 변환 방법이 고정돼 있다. 그래서 간단하게 BitConverter 타입에서는 GetBytes 메서드를 통해 이런 기능을 제공한다.
기본 타입의 값을 바이트 배열로 변환 및 복원
//기본 타입을 바이트 배열로 변환
byte[] boolBytes = BitConverter.GetBytes(true);
byte[] shortBytes = BitConverter.GetBytes((short)32000);
byte[] intBytes = BitConverter.GetBytes(1652300);
//바이트 배열을 기본 타입으로 복원
//두 번째 인수는 변환을 시작할 배열의 인덱스를 나타냅니다.
bool boolResult = BitConverter.ToBoolean(boolBytes, 0);
short shortResult = BitConverter.ToInt16(shortBytes, 0);
int intResult = BitConverter.ToInt32(intBytes, 0);
BitConverter로 변환된 바이트 배열은 리틀 엔디언(little endian)임
#인텔 호환 CPU에서는 모두 리틀 엔디언을 사용한다. 반면 RISC 프로세서 계열에서는 빅 엔디언을 사용한다.
#직렬화 수단으로 문자열을 사용
int n = 1652300;
string text = n.ToString(); //숫자 1652300을 문자열로 직렬화
int result = int.Parse(text); //문자열로부터 숫자를 역직렬화해서 복원
//parse (문장을 문법적으로) 분석하다
#System.IO.MemoryStream
MemoryStream은 Stream 추상 클래스를 상속받은 타입이다. 여기서 Stream 타입은 일련의 바이트를 일관성 있게 다루는 공통 기반을 제공한다.
Stream에는 데이터를 쓰거나 읽는 작업을 순서대로 하는 것이 기본 정책이다. MemoryStream 타입은 이름 그대로 메모리에 바이트 데이터를 순서대로 읽고 쓰는 작업을 수행하는 클래스다. 또한 이를 이용하면 데이터를 메모리에 직렬화/역직렬화하는 것이 가능하다.
static void Main(string[] args)
{
//기본 타입을 바이트 배열로 변환
byte[] boolBytes = BitConverter.GetBytes(true);
byte[] shortBytes = BitConverter.GetBytes((short)32000);
byte[] intBytes = BitConverter.GetBytes(1652300);
MemoryStream ms = new MemoryStream();
ms.Write(shortBytes, 0, shortBytes.Length);
ms.Write(intBytes, 0, intBytes.Length);
ms.Position = 0;
//MemoryStream으로부터 short 데이터를 역직렬화
byte[] outBytes = new byte[2];
ms.Read(outBytes, 0, 2);
int shortResult = BitConverter.ToInt16(shortBytes, 0);
Console.WriteLine(shortResult); //출력 결과: 32000
//이어서 int 데이터를 역직렬화
outBytes = new byte[4];
ms.Read(outBytes, 0, 4);
int intResult = BitConverter.ToInt32(intBytes, 0);
Console.WriteLine(intResult); //출력 결과: 1652300
}
Position 멤버의 위치가 읽고 쓰는 작업에 따라 증가해 순서대로 바이트 배열이 다뤄지는 것이 Stream의 특징이다. 닷넷에서 Stream을 상속받은 모든 타입의 기본 동작은 위의 쓰임과 같다.
MemoryStream이 내부적으로 유지하고 있는 바이트 배열을 얻기 위해 ToArray 메서드를 호출할 수 있다.
//short와 int 데이터를 순서대로 MemoryStream에 직렬화
byte shortBytes = BitConverter.GetBytes(32000);
byte intBytes = BitConverter.GetBytes(1652300);
MemoryStream ms = new MemoryStream();
ms.Write(shortBytes, 0, shortBytes.Length);
ms.Write(intBytes, 0, intBytes.Length);
byte[] buf = ms.ToArray(); //MemoryStream에 담긴 바이트 배열을 반환
//바이트 배열로부터 short 데이터를 역직렬화
short shortResult = BitConverter.ToInt16(buf, 0);
//이어서 int 데이터를 역직렬화
//byte 배열에는 Position 기능이 없으므로 ToInt32 메서드가 취해야 할 바이트의 위치를 명시함
int intResult = BitConverter.ToInt32(buf, 2);
#System.IO.StreamWriter / System.IO.StreamReader
Stream에 문자열 데이터를 쓰려면 반드시 그 전에 Encoding 타입을 이용해 바이트 배열로 변환해야 함.
위와 같은 번거로움을 해소하기 위해 BCL에 StreamWriter 타입이 있다.
MemoryStream ms = new MemoryStream();
//기존 방식
//byte[] buf = Encoding.UTF8.GetBytes("Hello World!");
//ms.Write(buf, 0, buf.Length);
//ms.Position = 0; // 스트림의 위치를 처음으로 되돌립니다.
//byte[] readBuf = new byte[ms.Length];
//ms.Read(readBuf, 0, readBuf.Length);
//string txt = Encoding.UTF8.GetString(readBuf);
//Console.WriteLine(txt);
//StreamWriter / streamReader 타입 이용
StreamWriter sw = new StreamWriter(ms, Encoding.UTF8);
sw.WriteLine("Hello World!");
sw.Flush();
ms.Position = 0; //읽기&쓰기 작업시 포지션 자동 이동; 원위치 필요
StreamReader sr = new StreamReader(ms, Encoding.UTF8);
string txt = sr.ReadToEnd();
Console.WriteLine(txt);
StreamWriter 타입은 생성자로 Stream과 문자열 인코딩 방식을 받는다. 이후 Write 계열의 메서드가 호출되면 인자로 입력한 문자열을 인코딩 방식에 따라 자동으로 바이트 배열로 변환한 후 Stream에 쓴다. 인자가 문자열이 아니어도 ToString 메서드를 이용해 변환한 문자열을 쓴다.
StreamWriter는 내부적으로 속도 향상을 위한 바이트 배열 버퍼를 가지고 있어서 들어온 문자열이 일정한 크기에 다다르면 한꺼번에 Stream으로 쓰기 작업을 한다.(짧은 데이터를 메서드를 호출할 때마다 Stream에 쓰는 것은 비효율적) Flush() 메서드는 문자열이 채워지지 않아도 Stream으로 쓰는 역할을 한다.
#System.IO.BinaryWriter / System.IO.BinaryReader
StreamWriter/StreamReader가 stream에 문자열 데이터를 쓰고 읽는 데 편리함을 준다면 BinaryWriter/BinaryReader는 Stream에 2진 데이터를 쓰고 읽는 데 특화된 기능을 제공한다.
static void Main(string[] args)
{
MemoryStream ms = new MemoryStream();
BinaryWriter bw = new BinaryWriter(ms);
//Environment.NewLine은 현재 환경에 맞는 줄바꿈 문자를 나타냅니다.
bw.Write("Hello World" + Environment.NewLine);
bw.Write("Anderson" + Environment.NewLine);
bw.Write(32000);
bw.Flush();
ms.Position = 0;
BinaryReader br = new BinaryReader(ms);
string first = br.ReadString();
string second = br.ReadString();
int result = br.ReadInt32();
Console.Write("{0}{1}{2}", first, second, result);
}
StreamWriter 바이트 데이터: 인코딩 정보 - {문자열 - 개행}(반복)
BinaryWriter 바이트 데이터: {유효 데이터 길이 - 데이터}(반복)
일반적으로 사람이 쉽게 읽을 수 있는 데이터를 원하는 경우 -> StreamWriter/Reader
데이터의 가독성은 떨어지더라도 규격이 정해진 데이터를 입출력 하는 경우 -> BinaryWriter/Reader
#System.Xml.Serialization.XmlSerializer
지금까지의 직렬화/역직렬화가 기본 자료형의 경우였다면, 이번 것은 사용자 정의 클래스의 경우
XmlSerializer는 이름에서 의미하는 것처럼 클래스의 내용을 XML 문자열로 직렬화한다. 편리한 직렬화 수단이긴 하지만 대신 다음과 같은 제약 사항이 있다.
- public 접근 제한자의 클래스여야 한다.
- 기본 생성자를 포함하고 있어야 한다.
- public 접근 제한자가 적용된 필드만 직렬화/역직렬화 한다.
using System;
using System.Text;
using System.Xml.Serialization;
public class Person
{
public int Age;
public string Name;
public Person()
{
}
public Person(int age, string name)
{
this.Age = age;
this.Name = name;
}
public override string ToString()
{
return string.Format("{0} {1}", this.Age, this.Name);
}
}
class Program
{
static void Main(string[] args)
{
MemoryStream ms = new MemoryStream();
XmlSerializer xs = new XmlSerializer(typeof(Person));
Person person = new Person(36, "Anderson");
//MemoryStream에 문자열로 person 객체를 직렬화
xs.Serialize(ms, person);
ms.Position = 0;
//MemoryStream로부터 객체를 역직렬화해서 복원
Person clone = xs.Deserialize(ms) as Person;
Console.WriteLine(clone);
//XmlSerializer는 기본적으로 UTF-8 인코딩으로 객체를 문자열로 직렬화한다.
//따라서 MemoryStream의 내용을 문자열로 변환해 그 내용을 확인할 수 있다.
byte[] buf = ms.ToArray();
Console.WriteLine(Encoding.UTF8.GetString(buf));
}
}
string text = @"<?xml version=""1.0"" encoding=""utf-8""?>
<Person xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance"" xmlns:xsd=""http://www.w3.org/2001/XMLSchema"">
<Age>11</Age>
<Name>ManakaRara</Name>
</Person>
";
byte[] buf = Encoding.UTF8.GetBytes(text);
MemoryStream ms1 = new MemoryStream(buf);
XmlSerializer xs1 = new XmlSerializer(typeof(Person));
Person clone1 = xs1.Deserialize(ms1) as Person;
Console.WriteLine(clone1);
위와 같이 텍스트를 전송받은 C# 프로그램은 XmlSerializer를 이용해 데이터를 역직렬화하면 된다.
#System.Text.Json.JsonSerializer
앞선 XmlSerializer는 문자열이 다소 길어진다는 단점을 극복하기 제공되는 타입 Json(JavaScript Object Notation)
JsonSerializer는 자바스크립트의 객체 직렬화 방식을 닷넷에서 동일하게 구현한다. 최근에 더 선호
class Program
{
static void Main(string[] args)
{
Person person = new Person(36, "Anderson");
JsonSerializerOptions options = new JsonSerializerOptions { IncludeFields = true }; //객체 이니셜라이저
string text = JsonSerializer.Serialize(person, options);
Console.WriteLine(text);
//<person> 구문은 7.1 제네릭 절에서 다룸
Person clone = JsonSerializer.Deserialize<Person>(text, options);
Console.WriteLine(clone);
}
}
BitConverter, Encoding, XmlSerializer - num, string, class
#컬렉션
지금까지 배운 배열은 크기가 고정돼 있다. 변수 자체에 대해서는 재할당을 통해 크기를 바꾸는 것이 가능하지만 이전 데이터는 보존되지 않는다. 프로그래밍하다 보면 정해지지 않은 크기의 배열을 다뤄야 할 필요가 있는데, 이런 기능을 편리하게 구현한 것을 컬렉션(collection)이라 한다. 동적으로 크기가 변하는 배열
#System.Collections.ArrayList
ArrayList는 object 타입 및 그와 형 변환할 수 있는 모든 타입을 인자로 받아 컬렉션에 추가/삭제/변경/조회할 수 있는 기능을 구현한 타입이다.(크기가 자유롭게 변할 수 있는 배열)
class Program
{
static void Main(string[] args)
{
ArrayList ar = new ArrayList();
//4개의 요소를 컬렉션에 추가
ar.Add("Hello");
ar.Add(6);
ar.Add("World");
ar.Add(true);
//숫자 6을 포함하고 있는지 판단
Console.WriteLine("Contains(6): " + ar.Contains(6));
//"World" 문자열을 컬렉션에서 삭제
ar.Remove("World");
//2번째 요소의 값을 false로 변경
ar[2] = false;
Console.WriteLine();
//컬렉션의 요소를 모두 출력
foreach(object obj in ar)
{
Console.WriteLine(obj);
}
}
}
ArrayList는 object를 인자로 갖기 때문에 닷넷의 모든 타입을 담을 수 있다는 장점이 있지만, 반대로 이로 인해 박싱(박싱(Boxing)은 값 형식(Value Type)을 참조 형식(Reference Type)으로 변환하여 힙에 저장하는 과정을 의미한다.)이 발생한다는 단점이 있다.
따라서 System.ValueType을 상속받는 값 형식을 위한 컬렉션으로는 적당하지 않다. 이를 해결하기 위해서는 닷넷 2.0부터 지원되는 제네릭(Generic)이 적용된 List<T> 타입을 사용하는 것이 권장된다.
Array.Sort -> 정적 메서드; ArrayList -> 인스턴스 메서드로 Sort가 제공, Sort 메서드를 호출할 때 제약사항이 있다면 ArrayList 안에 있는 요소가 모두 같은 타입이여야 함.(사용자 정의 타입일 경우 IComparable 인터페이스 이용)
#System.Collections.Hashtable
이 컬렉션은 값(value)뿐만 아니라 해시에 사용되는 키(key)가 추가되어 빠른 검색 속도를 자랑한다. 따라서 검색 속도의 중요도에 따라 ArrayList 또는 Hashtable 중 어느 것을 선택할지 결정한다.
Hashtable을 사용할 때 한 가지 주의할 점이 있다면 키 값이 중복되는 경우 Add 메서드에서 ArgumentException 예외가 발생하므로 중복에 주의를 기울여야 한다는 것이다. 또한 Hashtable은 ArrayList와는 달리 키 값도 내부적으로 보관하고 있기 때문에 그만큼의 메모리가 낭비된다는 단점이 있다. 게다가 키와 값이 모두 object 타입으로 다뤄지기 때문에 Hashtable에서도 박싱 문제가 발생한다.
#System.Collections.SortedList
Hashtable에서는 키가 해시되어 데이터를 가리키는 인덱스 용도로 사용됐던 반면 SortedList의 키는 그 자체가 정렬되어 값의 순서에 영향을 준다. SortedList는 메서드를 호출할 필요 없이 Add 메서드에 요소가 삽입될 때마다 바로 정렬된다. 정렬은 키로 전달되는 첫 번째 인자를 기준으로 하고 두 번째 인자로 전달된 값은 키의 정렬에 따라 순서가 바뀐다. 그 밖에 Hashtable과 마찬가지로 키 값이 중복되는 경우 예외가 발생함.
#System.Collections.Stack
선입후출(FILO: First-In Last-Out), push, pop 메서드를 지원함
Stack 타입 역시 object를 인자로 다루기 때문에 박싱 문제가 발생함.
#System.Collections.Queue
선입선출(FIFO:First-In First-Out), Enqueue, Dequeue 메서드를 지원함
Queue 타입 역시 object를 인자로 다루기 때문에 박싱 문제가 발생함.
#파일
데이터를 영구 저장한다는 의미에서 파일은 컴퓨터의 발전 과정에서 매우 중요한 역할을 해왔다. 심지어 윈도우가 나오기 이전의 운영체제를 DOS(Disk Operating System)라고 불렀는데, 그 이름에 디스크(Disk)가 들어갈 정도로 파일 관리는 이미 핵심적인 위치를 차지하고 있었다.
#System.IO.FileStream
FileStream은 파일을 다루기 위한 BCL의 가장 기본적인 타입이다. MemoryStream의 부모 클래스와 동일한 Stream 타입을 상속받았고 전체적인 동작 방식도 MemoryStream과 유사하다. 다른 점이 있다면 MemoryStream은 메모리에 할당한 바이트 배열을 대상으로 읽기/쓰기 작업을 했지만, FileStream은 디스크의 파일을 대상으로 읽기/쓰기 작업을 한다.
FileStream에 대해 알아야 할 나머지 사항으로는 생성자의 인자로 전달되는 FileMode, FileAccess, FileShare가 있다. 이 세 가지 모두 열거형이며, 각 인자의 사용법은 다음과 같다.
#FileMode
열거형 값 | 설명 |
CreateNew | 파일을 항상 새롭게 생성한다. 같은 이름의 파일이 이미 있다면 IOException 예외가 발생한다. |
Create | 파일을 무조건 생성한다. 같은 이름의 파일이 이미 있다면 기존 데이터가 모두 삭제된다. |
Open | 이미 있는 파일을 연다. 만약 지정된 이름의 파일이 존재하지 않는다면 FileNotFoundException 예외가 발생한다. |
OpenOrCreate | 같은 이름의 파일이 이미 있다면 열고, 없다면 생성한다. |
Truncate | 이미 있는 파일을 열고 기존 데이터는 모두 삭제한다. 같은 이름의 파일이 존재하지 않는다면 FileNotFoundException 예외가 발생한다. |
Append | 파일을 무조건 연다. 같은 이름의 파일이 있다면 FileStream의 Position 값을 마지막 위치로 자동으로 이동시킨다. 같은 이름의 파일이 없다면 새롭게 생성한다. |
#FileAccess
열거형 값 | 설명 |
Read | 파일을 읽기 목적으로 연다. |
Write | 파일을 쓰기 목적으로 연다. |
ReadWrite | 파일을 읽기 및 쓰기 목적으로 연다. 이 모드는 FileAccess.Read | FileAccess.Write로 지정한 것과 같다. |
#FileShare
열거형 값 | 설명 |
None | 같은 파일에 대해 두 번 이상 여는 경우 무조건 실패한다. 즉, 맨 처음 파일을 열고 있는 FileStream만이 해당 파일을 사용할 수 있다. |
Read | 같은 파일에 대해 FileAccess.Read로 여는 것만 허용한다. 맨 처음 파일을 여는 FileStream은 모든 동작을 할 수 있지만, 이후에 그 파일을 열려는 시도는 오직 읽기 모드로만 허용된다. |
Write | 같은 파일에 대해 FileAccess.Write로 여는 것만 허용한다. 맨 처음 파일을 여는 FileStream은 모든 동작을 할 수 있지만, 이후에 그 파일을 열려는 시도는 오직 쓰기 모드로만 허용된다. |
ReadWrite | 같은 파일에 대해 FileAccess.Read 또는 FileAccess.Write, 또는 그 두 가지 모두 지정된 목적으로 여는 것을 혀용한다. 즉, 같은 파일에 대해 서로 다른 FileStream에서 읽고 쓰는 것이 가능하다. |
#자주 사용되는 옵션
옵션 조합 | 설명 |
FileMode.Append | 로깅(logging) 목적의 파일 쓰기를 하는 경우 사용한다. (FileMode.Append인 경우 FileAccess는 Write만 허용한다. 또한 FileShare의 기본값은 Read이므로 굳이 지정할 필요가 없다.) |
FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None |
재사용되는 전용 데이터를 입/출력하는 목적인 경우 사용한다. |
FileMode.Create, FileAccess.ReadWrite, FileShare.None |
임시로 사용되는 데이터를 입/출력하는 목적인 경우 사용한다. |
기본 경로는 Environment.CurrentDirectory의 값을 따름 가령 "test.log" 인 경우
Environment.CurrentDirectory + @"\test.log"
#System.IO.File / System.IO.FileInfo
File 타입은 자주 사용되는 파일 조작 기능을 담은 정적 클래스다. 따라서 File 타입에서 제공되는 모든 메서드는 정적 메서드다.
#File 타입의 정적 메서드
정적 메서드 | 설명 |
Copy | 파일을 복사한다. |
Exists | 파일이 존재하는지 여부를 true/false로 반환한다. |
Move | 파일을 이동한다. |
ReadAllBytes | 파일의 모든 내용을 읽어 byte 배열로 반환한다. |
ReadAllLines | 텍스트 파일의 모든 내용을 string배열로 반환한다. 한 줄당 문자열 하나로 대응된다. |
WriteAllBytes | 지정된 Byte 배열을 모두 파일에 쓴다. |
WriteAllLines | 지정된 string 배열의 모든 내용을 개행 문자와 함께 파일을 쓴다. |
WriteAllText | 지정된 string 인자의 값을 모두 파일에 쓴다. |
File은 경로가 지정되지 않으면 Environment.CurrentDirectory가 기본 경로로 사용
File.Move 메서드는 파일의 위치를 옮기는 목적 말고도 파일명을 변경(rename)하는 용도로도 사용된다.
File 타입은 정적 클래스다. 반면 FileInfo 타입은 File 타입의 기능을 인스턴스 멤버로 일부 구현하고 있다는 차이점을 제외하고 거의 모든 면에서 사용법이 같다.
#System.IO.Directory / System.IO.DirectoryInfo
Directory와 DirectoryInfo 타입의 관계도 File/FileInfo의 관계와 동일하다.
#Directory 타입의 정적 메서드
정적 메서드 | 설명 |
CreateDirectory | 디렉터리를 생성한다. 이미 디렉터리가 존재한다면 아무런 작업도 하지 않는다. |
Delete | 디렉터리를 삭제한다. 존재하지 않는 디렉터리를 삭제하는 경우 DirectoryNotFoundException 예외가 발생한다. |
Exists | 디렉터리가 존재하는지 여부를 true/false로 반환한다. |
GetDirectories | 지정된 경로의 하위 디렉터리 목록을 문자열 배열로 반환한다. |
GetFiles | 지정된 경로에 있는 파일을 문자열 배열로 반환한다. |
GetLogicalDrives | 시스템에 설치된 디스크의 드라이브 문자 목록을 string 배열로 반환한다. |
Move | 디렉터리를 이동한다. |
++C#에서 문자열 앞에 @ 문자를 사용하면 그 문자열은 리터럴 문자열로 간주됩니다. 리터럴 문자열은 문자열 내부의 이스케이프 문자를 무시하고, 있는 그대로의 텍스트를 저장합니다. 즉, 문자열 내에서 백슬래시(\) 같은 이스케이프 시퀀스를 사용하지 않고도 문자열을 작성할 수 있습니다.
class Program
{
static void Main(string[] args)
{
string targetPath = @"C:\Users\ndhph\Desktop";
foreach(string txt in Directory.GetFiles(targetPath, "*.exe",
SearchOption.AllDirectories)) //targetPath 아래 모든 exe 파일 찾아서 출력
{
Console.WriteLine(txt);
}
}
}
GetFiles의 두 번째 인자인 "*.exe"에서 사용된 별표(asterisk) 문자를 가리켜 와일드카드 문자(wildcard character)라고 한다. *는 전부, ?는 임의의 문자
#와일드카드 예
문자열 | 의미 | 사례 |
net*.* | 확장자는 상관없고, 파일명이 'net'으로 시작하는 모든 파일 | netframework.dll net_.dat nettest.exe |
net?.* | 확장자는 상관없고, 'net'으로 시작하는 총 4글자로 된 파일명을 가진 모든 파일 | net1.dll net_.exe netp.dat |
???.dll | 확장자가 DLL이고, 파일명이 3글자인 모든 파일 | tes.dll fra.dll kor.dll |
*. | 확장자가 없는 모든 파일 | netfx |
*script.* | 확장자는 상관없고, 파일명이 script로 끝나는 모든 파일 | Microsoft.JScript.dll VBScript.tlb |
#System.IO.Path
Path 정적 메서드
정적 메서드 | 설명 |
ChangeExtension | 첫 번째 인자로 주어진 경로에서 확장자 부분을 두 번째 인자로 전달된 문자열로 바꿔준다. |
Combine | 전달된 문자열 인자를 모두 합쳐서 하나의 경로로 만든다. |
GetDirectoryName | 전달된 문자열에서 파일 이름이 포함된 경우 그 파일의 부모 디렉터리 이름을 반환한다. 반면 디렉터리 이름이 포함된 경우 그 부모 디렉터리 이름을 반환한다. |
GetExtension | 전달된 문자열의 확장자를 반환한다. |
GetFileName | 전달된 문자열의 파일명을 반환한다. |
GetFileNameWithoutExtension | 전달된 문자열의 파일명을 확장자를 제외시켜 반환한다. |
GetFullPath | 전달된 문자열의 파일명을 제외한 경로를 반환한다. |
GetInvalidFileNameChars | 파일 이름으로 부적절한 문자의 배열을 반환한다. |
GetInvalidPathChars | 경로 이름으로 부적절한 문자의 배열을 반환한다. |
GetPathRoot | 전달된 문자열의 루트 드라이브 문자열을 반환한다. |
GetRandomFileName | 임의의 파일명을 반환한다. |
GetTempFileName | 윈도우의 임시 폴더 경로에 임의의 파일을 생성하고 그 경로를 반환한다. |
GetTempPath | 윈도우의 임시 폴더 경로를 반환한다. |
class Program
{
static void Main(string[] args)
{
string newDirName = "my<new";
if(newDirName.IndexOfAny(Path.GetInvalidPathChars()) == -1)
{
Console.WriteLine("폴더명에 적절하지 않은 문자가 있음.");
}
}
}
윈도우에는 '임시 폴더(temporary folder)'라는 것이 있다. 말 그대로 프로그램에서 임시 목적의 파일을 생성하기에 적당한 폴더인데, Path.GetTempPath 메서드를 통해 그 경로를 구할 수 있다.
class Program
{
static void Main(string[] args)
{
//크기가 0인 임시 파일을 생성하고 그 경로를 반환한다.
string createdTempFilePath = Path.GetTempFileName();
Console.WriteLine(createdTempFilePath);
//임시 파일을 생성하지 않고 중복될 확률이 낮은 임시 파일 경로를 구한다.
string tempFilePath
= Path.Combine(Path.GetTempFileName(), Path.GetRandomFileName());
Console.WriteLine(tempFilePath);
}
}
#스레딩
스레드(thread)는 명령어를 실행하기 위한 스케줄링 단위이며, 프로세스 내부에서 생성할 수 있다. 이는 운영체제에서 멀티 스레딩을 지원한다면 하나의 프로세스가 여러 개의 스레드 자원을 가질 수 있음을 의미한다.
윈도우는 프로세스를 생성할 때 기본적으로 한 개의 스레드를 함께 생성하며, 이를 주 스레드(main thread, primary thread)라고 한다.
스레드는 CPU의 명령어 실행과 관련된 정보를 보관하고 있는데, 이를 스레드 문맥(thread context)이라 한다. 운영체제의 스케줄러는 실행돼야 할 적절한 스레드를 골라서 CPU로 하여금 실행되게 만드는데, 이때 두 가지 동작을 수행한다.
CPU는 현재 실행 중인 스레드를 다음에 다시 이어서 실행할 수 있게 CPU의 환경 정보를 스레드 문맥에 보관한다. 그리고 운영체제로부터 할당받은 스레드의 문맥 정보를 다시 CPU 내부로 로드해서 마치 해당 스레드가 실행되고 있었던 상태인 것처럼 복원한 다음, 일정 시간 동안 실행을 계속한다.
#System.Threading.Thread
프로그램이 실행되면 주 스레드 하나가 기본적으로 생성됨 Thread 타입에는 현재 명령어를 실행 중인 스레드 자원에 접근할 수 있는 정적 속성을 제공한다.
Thread thread = Thread.CurrentThread;
Console.WriteLine(thread.ThreadState);
Console.WriteLine(DateTime.Now);
Thread.Sleep(1000); //1초 동안 스레드 중지
Console.WriteLine(DateTime.Now);
#스레드 생성
class Program
{
static void Main(string[] args)
{
Thread t = new Thread(threadFunc);
t.Start();
}
static void threadFunc()
{
Console.WriteLine("threadFunc run!");
}
}
새롭게 생선된 스레드는 별도로 명령어를 실행해 나감. 다중 코어 CPU에서는 실제로 주 스레드와 t 스레드의 코드를 동시에 실행할 수 있음.
스레드의 종료는 결국 프로그램의 종료에 해당한다. 기본적으로 프로그램은 생성된 모든 스레드가 실행을 종료해야만 프로그램도 종료할 수 있다.
//true: background thread로 설정: 모든 포그라운드 스레드가 종료되면 자동으로 종료됨
//false: foreground thread로 설정
threadName.IsBackground = true; //= false;
//threadName이 종료할 때까지 대기함
threadName.Join();
class ThreadParam
{
public int Value1;
public int Value2;
}
class Program
{
static void Main(string[] args)
{
//인자가 있는 메서드의 경우 Thread 생성자는
//ParameterizedThreadStart 델리게이트 타입을 허용한다.
Thread t = new Thread(threadFunc);
//따라서 C# 컴파일러는 위의 코드를 다음과 같이 번역해 컴파일한다.
//new Thread(new ParameterizedThreadStart(threadFunc));
ThreadParam param = new ThreadParam();
param.Value1 = 10;
param.Value2 = 20;
t.Start(param);
}
static void threadFunc(object initialValue)
{
ThreadParam value = (ThreadParam)initialValue;
Console.WriteLine("{0}, {1}", value.Value1, value.Value2);
}
}
#System.Threading.Monitor
스레드는 메모리가 허용하는 한 원하는 만큼 생성할 수 있다. 이전에 한 개의 스레드에 할당된 스택의 용량이 1MB라고 설명한 바 있다. 32비트 윈도우에서 32비트 프로세스는 2GB의 사용자 메모리가 허용되므로 오로지 스레드에만 메모리가 사용된다고 가정해도 2,000개(1MB * 2048 = 2GB)를 넘을 수 없다. 하지만 64비트 시대가 열리면서 사용자 메모리에 테라바이트 단위로 할당이 가능하므로 사실상 이제는 컴퓨터 성능만 받쳐준다면 스레드 수에 제한이 풀렸다.
#다중 스레드
기본적으로 생성한 스레드는 실행 순서를 장담할 수 없다.
공유 리소스(shared resource)에 대한 스레드의 동기화(synchronization) 처리 문제
-> Monitor 클래스를 사용함
static void threadFunc(object inst)
{
Program pg = inst as Program;
for(int i = 0; i < 100000; i++)
{
Monitor.Enter(pg);
try
{
pg.number = pg.number + 1;
}
finally
{
Monitor.Exit(pg);
}
}
}
Monitor.Enter/Exit 사이에 위치한 모든 코드는 한 순간에 스레드 하나만 진입해서 실행할 수 있다는 점을 기억하자.
Enter와 Exit 메서드의 인자로 전달하는 값은 반드시 참조형 타입의 인스턴스여야 한다.
static void threadFunc(object inst)
{
Program pg = inst as Program;
for(int i = 0; i < 100000; i++)
{
lock(pg)
{
pg.number = pg.number + 1;
}
}
}
lock 예약어를 사용한 블럭도 C# 컴파일러에 의해 최종적으로는 try/finally + Monitor.Enter/Exit 코드로 바뀌기 때문에 완전히 동일하다.
using System;
class MyData
{
int number = 0;
public object _numberLock = new object();
public int Number { get { return number; } }
public void Increment() //non thread-safe
{
number++;
//lock() { number++; }로 구현해야 thread-safe 메서드가 된다.
}
}
class Program
{
static void Main(string[] args)
{
MyData data = new MyData();
Thread t1 = new Thread(threadFunc);
Thread t2 = new Thread(threadFunc);
t1.Start(data);
t2.Start(data);
t1.Join();
t2.Join();
Console.WriteLine(data.Number);
}
static void threadFunc(object inst)
{
MyData data = inst as MyData;
for(int i = 0; i < 100000; i++)
{
data.Increment();
//lock(data){ Increment(); } 외부에서 non thread-safe 문제 해결
}
}
}
마이크로소프트의 모든 BCL 도움말에는 해당 타입의 메서드에 대한 스레드 안전성(thread safety) 여부를 명시하고 있다.
모든 메서드를 처음부터 스레드에 안전한 방식으로 만들지 않는 이유는 성능 문제; lock 보호 장치가 들어가야 하겠지만, 대부분의 경우 단일 스레드에서만 접근하기 때문에 부수적인 lock 보호 장치는 성능상 좋지 않다.
BCL의 모든 타입을 사용할 때는 인스턴스 멤버에 대해 기본적으로 스레드에 안전하지 않다는 점을 염두에 두고, 동기화가 필요할 때는 개발자가 직접 외부에서 처리해야 한다.
#System.Threading.Interlocked
Interlocked 타입은 정적 클래스다. 다중 스레드에서 공유자원을 사용하는 몇몇 패턴에 대해서는 명시적인 동기화 작업을 필요 없게 만드는 정적 메서드를 제공한다.
32비트/64비트 숫자형 타입의 더하기 및 증가/감소와 같은 일부 연산에 대해서는 lock(또는 Monitor)을 사용하지 않고도 Interlocked 타입을 이용해 처리할 수 있다.
class MyData
{
int number = 0;
public int Number { get { return number; } }
public void Increment()
{
Interlocked.Increment(ref number);
}
}
Interlocked 타입의 정적 메서드로 제공되는 연산의 단위를 '원자적 연산(atomic operation)'이라 한다. 일반적으로 '원자'란 더 이상 쪼갤 수 없는 단위를 일컫는데, 원자적 연산도 같은 맥락으로 이해할 수 있다. 즉, 원자적 연산이란 하나의 스레드가 그 연산 상태에 들어갔을 때 그 연산은 더는 나뉠 수 없는 단일 연산으로 취급받기 때문에 다른 스레드가 중간에 개입할 수 없을 의미한다.
가령 int64 n =5;를 32비트 운영체제에서 수행하는 경우 1)메모리 하위 32비트에 0x00000000을 쓰고, 2)메모리의 상위 32비트에 0x05000000을 쓰기 때문에 원자적 연산이 아니다.(2번의 연산) 만약 스레드가 1번 연산을 수행하고 2번 연산으로 들어가기 전에 운영체제에 의해 실행이 중단되고, 이후 변수 값을 얻는다면 5가 아닌 다른 값을 얻게 된다.
long n = 0;
//32/64bit 컴퓨터 상관없이 long 타입에 값을 대입하는 작업을 원자적 단위로 수행함
Interlocked.Exchange(ref n, 5);
lock 구문의 블록에 있는 모든 연산은 논리적으로 원자적 연상에 속한다. 스레드 입장에서 lock 블록의 코드가 실행되는 동안 다른 스레드가 절대 그 연산의 중간에 끼어들 수 없다.
#System.Threading.ThreadPool
스레드의 동작 방식은 Thread 타입의 생성자에 전달되는 메서드의 코드 유형에 따라 크게 두 가지로 나뉜다.
- 상시 실행: 스레드가 일단 생성되면 비교적 오랜 시간 동안 생성돼 있는 유형이다. 대개의 경우 무한 루프를 가지고 있다.
- 일회성의 임시 실행: 특정 연산만을 수행하고 바로 종료하는 유형이다.
1번 유형을 위해 스레드를 생성하고 유지하는 것은 당연하겠지만, 2번 유형 때문에 매번 스레드를 생성하는 것은 다소 불편할 수 있다. 임시적인 목적으로 언제든 원하는 때에 스레드를 사용하기 위해 CLR은 이런 용도로 사용할 수 있는 기본적인 스레드 풀(thread pool)이 있다.
프로그래밍에서 풀(pool)이라는 용어는 일반적으로 '재사용할 수 있는 자원의 집합'을 의미한다. 따라서 스레드 풀이라고 하면 필요할 때마다 스레드를 꺼내 쓰고 필요없어지면 다시 풀에 스레드가 반환되는 기능을 일컫는다.
class Program
{
static void Main(string[] args)
{
MyData data = new MyData();
//QueueUserWorkItem은 1개의 인자만을 스레드 메서드에 전달하도록 허용하기 때문에
//인자가 추가로 필요하다면 Hashtable 등과 같은 형식으로 보내줘야 함
ThreadPool.QueueUserWorkItem(threadFunc, data);
ThreadPool.QueueUserWorkItem(threadFunc, data);
Thread.Sleep(1000);
Console.WriteLine(data.Number);
}
//threadFunc 생략
}
스레드 생성 코드 생략, 대신 스레드 생성자에 전달됐던 메서드를 곧바로 ThreadPool 타입의 QueueUserWorkItem 메서드에 전달하고 있다. 두 번 호출했기 때문에 스레드 풀에는 2개의 스레드가 자동으로 생성되고 각 스레드에 threadFunc 메서드가 할당되어 실행됨.
#ThreadPool 내부 동작 방식
- ThreadPool은 프로그램 시작과 함께 0개의 스레드를 가지며 생성됨
- QueueUserWorkItem을 호출했을 때 ThreadPool에 스레드가 없으면 생성하고 할당함, 남는게 있다면 여기에 할당시킴
- 스레드는 곧바로 종료되지 않고 스레드 풀에 일정 시간 동안 보관된다. 보관돼 있는 시간 동안 다시 QueueUserWorkItem이 실행되어 스레드가 필요해지면 곧바로 활성화되어 주어진 메서드를 실행한다.
- 일정 시간 동안 재사용되지 않는다면 스레드는 풀로부터 제거되어 완전히 종료된다.
한 번 생성된 스레드는 일정 시간 동안 재사용된다는 점이 ThreadPool 주요 특징 중 하나다. 여기서 스레드가 운영체제의 커널 자원으로 생성된다는 점을 염두에 둘 필요가 있다. 이는 스레드 하나를 생성/종료하는 데 소비되는 CPU 사용량이 크다는 것을 의미한다. 따라서 스레드를 자주 생성해서 사용하는 프로그램이 있다면 매번 Thread 객체를 생성하기보다는 ThreadPool로부터 재사용했을 때 더 나은 성능을 보인다.
Q. 커널자원을 사용하는게 사용자 자원보다 CPU 사용량이 많아? 많다면 왜야
A.네, 커널 자원을 사용하는 것이 사용자 자원보다 CPU 사용량이 더 많을 수 있습니다. 이는 주로 다음과 같은 이유들 때문입니다:
- 시스템 호출 (System Call) 오버헤드:
- 사용자 모드와 커널 모드 간의 전환은 비교적 무거운 작업입니다. 시스템 호출이 발생할 때, CPU는 사용자 모드에서 커널 모드로 전환되며, 이 과정에서 많은 상태 정보를 저장하고 복원해야 합니다.
- 이 전환 과정은 CPU 레지스터의 상태 저장, 메모리 매핑 테이블의 변경, 모드 비트 설정 등의 작업을 포함합니다.
- 안전성 및 보호:
- 커널은 시스템의 안정성과 보안을 유지하기 위해 많은 추가 검사를 수행합니다. 예를 들어, 자원 접근 권한 확인, 메모리 보호 검사 등을 수행합니다.
- 이러한 검사는 사용자 모드에서 수행되는 작업보다 더 많은 처리 시간을 요구할 수 있습니다.
- 다양한 하드웨어 접근:
- 커널 모드는 다양한 하드웨어 자원에 직접 접근하고 이를 관리합니다. 이 과정에서 다양한 하드웨어 레지스터를 읽고 쓰며, 장치 드라이버와 상호 작용합니다.
- 이러한 작업은 일반적인 사용자 모드의 연산보다 더 복잡하고 시간이 많이 소요될 수 있습니다.
- 멀티태스킹 및 스케줄링:
- 커널은 멀티태스킹 환경에서 여러 프로세스와 스레드를 관리하며, 이들을 스케줄링합니다. 스케줄링은 CPU 자원을 여러 프로세스에 공평하게 분배하기 위한 작업으로, 문맥 교환(context switching)과 같은 복잡한 작업을 수반합니다.
- 문맥 교환은 현재 실행 중인 프로세스의 상태를 저장하고, 새로운 프로세스의 상태를 복원하는 과정으로, 상당한 양의 CPU 시간을 소비합니다.
이러한 이유들로 인해 커널 자원을 사용하는 작업은 일반적인 사용자 모드 작업보다 CPU 사용량이 더 많을 수 있습니다. 커널 모드에서 수행되는 작업은 시스템의 안정성과 효율성을 위해 필수적이지만, 그만큼 더 많은 자원을 요구합니다.
#System.Threading.EventWaitHandle
EventWaitHandle은 Monitor 타입처럼 스레드 동기화 수단의 하나다. 스레드로 하여금 이벤트(event)를 기다리게 만들 수 있고, 다른 스레드에서는 원하는 이벤트를 발생시키는 시나리오에 적합하다. 이때 이벤트 객체는 딱 두 가지 상태만 갖는데, Signal과 Non-signal로 나뉘고 서로 간의 상태 변화는 Set, Reset 메서드로 전환할 수 있다.
이와 함께 이벤트 객체는 WaitOne 메서드를 제공한다. 어떤 스레드가 WaitOne 메서드를 호출하는 시점에 이벤트 객체가 Signal 상태이면 메서드에서 곧바로 제어가 반환되지만, Non-Signal 상태였다면 이벤트 객체가 Signal 상태로 바뀔 때까지 WaitOne 메서드는 제어를 반환하지 않는다. 즉, 스레드는 더는 실행하지 못하고 대기 상태로 빠진다.
WaitOne 메서드를 호출하면 이벤트 객체가 signal이 될 때까지 제어를 안줌
Join 메서드의 역할을 EventWaitHandle 객체로 우회해서 구현할 수 있기 때문에 이를 응용해 Join 메서드를 호출할 수 없었 ThreadPool의 단점을 보완할 수 있다.
이벤트는 크게 수동 리셋(manual reset) 이벤트와 자동 리셋(auto reset) 이벤트로 나뉜다. 두 리셋 방식의 차이점을 간단하게 설명하면 EventWaitHandle.Set 메서드를 호출해 Signal 상태로 전환된 이벤트가 Non-Signal 상태로 자동으로 전환되느냐 아니냐에 있다. Set을 호출한 후 자동으로 Non-Signal로 돌아오면 자동 리셋 이벤트이고, 명시적으로 개발자가 Reset 메서드를 호출해야 Non-Signal로 돌아오면 수동 리셋 이벤트다.
수동 리셋 이벤트의 특징은 명시적인 Reset 메서드를 호출하기까지 이벤트의 Signal 상태를 지속시킨다.
이벤트 객체의 상태에 따라 스레드의 동작이 결정됨
자동 리셋 이벤트 (AutoResetEvent)
- A 스레드가 WaitOne을 호출하여 이벤트를 기다립니다.
- B 스레드도 WaitOne을 호출하여 이벤트를 기다립니다.
- C 스레드가 이벤트의 Set 메서드를 호출하여 이벤트를 신호 상태로 설정합니다.
- 이벤트가 신호 상태가 되면, 대기 중인 스레드 중 하나(A 또는 B)가 깨어납니다.
- 이 때, 깨어나는 스레드는 시스템에 의해 결정되며, 이는 랜덤일 수 있습니다.
- 신호 상태로 전환된 이벤트는 즉시 비신호 상태로 돌아갑니다.
- 다른 스레드(B 또는 A)는 여전히 대기 상태로 남습니다.
즉, 자동 리셋 이벤트의 경우 한 번의 Set 호출로 대기 중인 스레드 중 하나만 깨어나고 이벤트는 다시 비신호 상태로 전환됩니다.
수동 리셋 이벤트 (ManualResetEvent)
- A 스레드가 WaitOne을 호출하여 이벤트를 기다립니다.
- B 스레드도 WaitOne을 호출하여 이벤트를 기다립니다.
- C 스레드가 이벤트의 Set 메서드를 호출하여 이벤트를 신호 상태로 설정합니다.
- 이벤트가 신호 상태가 되면, 대기 중인 모든 스레드(A와 B 모두)가 깨어납니다.
- 이벤트는 Reset 메서드가 호출될 때까지 신호 상태를 유지합니다.
즉, 수동 리셋 이벤트의 경우 한 번의 Set 호출로 대기 중인 모든 스레드가 깨어나고, 이벤트는 신호 상태를 유지하다가 Reset 메서드가 호출될 때 비신호 상태로 전환됩니다.
요약
- 자동 리셋 이벤트 (AutoResetEvent): 한 번의 Set 호출로 대기 중인 스레드 중 하나만 깨어나고, 다시 비신호 상태로 전환됩니다. 깨어나는 스레드는 랜덤으로 결정됩니다.
- 수동 리셋 이벤트 (ManualResetEvent): 한 번의 Set 호출로 대기 중인 모든 스레드가 깨어나고, 이벤트는 Reset 메서드가 호출될 때까지 신호 상태를 유지합니다.자동 리셋 이벤트 (AutoResetEvent)
- A 스레드가 WaitOne을 호출하여 이벤트를 기다립니다.
- B 스레드도 WaitOne을 호출하여 이벤트를 기다립니다.
- C 스레드가 이벤트의 Set 메서드를 호출하여 이벤트를 신호 상태로 설정합니다.
- 이벤트가 신호 상태가 되면, 대기 중인 스레드 중 하나(A 또는 B)가 깨어납니다.
- 이 때, 깨어나는 스레드는 시스템에 의해 결정되며, 이는 랜덤일 수 있습니다.
- 신호 상태로 전환된 이벤트는 즉시 비신호 상태로 돌아갑니다.
- 다른 스레드(B 또는 A)는 여전히 대기 상태로 남습니다.
- A 스레드가 WaitOne을 호출하여 이벤트를 기다립니다.
- B 스레드도 WaitOne을 호출하여 이벤트를 기다립니다.
- C 스레드가 이벤트의 Set 메서드를 호출하여 이벤트를 신호 상태로 설정합니다.
- 이벤트가 신호 상태가 되면, 대기 중인 모든 스레드(A와 B 모두)가 깨어납니다.
- 이벤트는 Reset 메서드가 호출될 때까지 신호 상태를 유지합니다.
- 자동 리셋 이벤트 (AutoResetEvent): 한 번의 Set 호출로 대기 중인 스레드 중 하나만 깨어나고, 다시 비신호 상태로 전환됩니다. 깨어나는 스레드는 랜덤으로 결정됩니다.
- 수동 리셋 이벤트 (ManualResetEvent): 한 번의 Set 호출로 대기 중인 모든 스레드가 깨어나고, 이벤트는 Reset 메서드가 호출될 때까지 신호 상태를 유지합니다.
- 요약
- 수동 리셋 이벤트 (ManualResetEvent)
#비동기 호출
- 동기 호출(synchronous call)은 한 작업이 완료될 때까지 프로그램이 다음 작업으로 넘어가지 않는 방식
- 비동기 호출(asynchronous call)은 작업이 시작된 후, 작업의 완료를 기다리지 않고 다음 작업으로 넘어가는 방식
일반적으로 비동기 호출은 입출력(I/O) 장치와 연계되어 설명할 때가 많다.
using System.Text;
class Program
{
static void Main(string[] args)
{
using (FileStream fs =
new FileStream(@"C:\windows\system32\drivers\etc\HOSTS",
FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
byte[] buf = new byte[fs.Length];
fs.Read(buf, 0, buf.Length);
string txt = Encoding.UTF8.GetString(buf);
Console.WriteLine(txt);
}
}
}
여기서 FileStream.Read 메서드는 동기 호출에 속한다. 즉, Read 메서드는 디스크의 파일로부터 데이터를 모두 읽기 전까진 제어를 반환하지 않는다. 이 때문에 다른 말로 동기 호출을 블로킹 호출(blocking call)이라고도 한다.
느린 디스크 I/O가 끝날 때까지 스레드가 아무 일도 못하고 놀게 되는 단점을 해결하기 위해 비동기 호출이 제공된다.
#BeginRead/EndRead, BeginWrite/EndWrite 메서드
using System;
using System.IO;
using System.Text;
class FileState
{
public byte[] Buffer;
public FileStream File;
}
class Program
{
static void Main(string[] args)
{
FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS",
FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, true);
FileState state = new FileState();
state.Buffer = new byte[fs.Length];
state.File = fs;
fs.BeginRead(state.Buffer, 0, state.Buffer.Length, readCompleted, state);
//BeginRead 비동기 메서드 호출은 스레드로 곧바로 제어를 반환하기 때문에
//이곳에서 자유롭게 다른 연산을 동시에 진행할 수 있다.
Console.ReadLine();
fs.Close();
}
//읽기 작업이 완료되면 메서드가 호출된다.
static void readCompleted(IAsyncResult ar)
{
FileState state = ar.AsyncState as FileState;
state.File.EndRead(ar);
string txt = Encoding.UTF8.GetString(state.Buffer);
Console.WriteLine(txt);
}
}
BeginRead 메서드는 디스크로부터 파일 데이터를 읽어낼 때까지 기다리지 않고 곧바로 스레드에 제어를 반환한다. 따라서 스레드는 이후의 코드를 끊김 없이 실행할 수 있다. 그리고 읽기 작업이 완료되면 CLR은 ThreadPool로부터 유휴 스레드를 하나 얻어와 그 스레드에 readCompleted 메서드의 실행을 맡긴다. 여기서 중요한 것은 BeginRead를 호출한 스레드를 전혀 방해하지 않는다는 점이다.
비동기 호출은 I/O 연산이 끝날 때까지 차단되지 않으므로 논블로킹 호출(non-blocking call)이라고도 한다.
using System.Text;
class Program
{
static void Main(string[] args)
{
ThreadPool.QueueUserWorkItem(readCompleted);
//QueueUserWorkItem 메서드 호출은 곧바로 제어를 반환하기 때문에
//이곳에서 자유롭게 다른 연산을 동시에 진행할 수 있다.
Console.ReadLine();
}
//읽기 작업을 스레드 풀에 대행한다.
static void readCompleted(object state)
{
using(FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS",
FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
byte[] buf = new byte[fs.Length];
fs.Read(buf, 0, buf.Length);
string txt = Encoding.UTF8.GetString(buf);
Console.WriteLine(txt);
}
}
}
일반적인 목적의 응용 프로그램에서 QueueUserWorkItem과 비교했을 때 비동기 호출로 얻는 이득은 크지 않다. 이 정도의 차이가 의미 있는 경우는 동시 접속자 수가 많은 게임 서버나 웹 서버 등이 있다.
#System.Delegate의 비동기 호출
일반적으로 비동기 호출은 입출력 장치와의 속도 차이에서 오는 비효율적인 스레드 사용 문제를 극복하는 데 사용된다. 그런데 닷넷에서는 특이하게도 입출력 장치뿐만 아니라 일반 메서드에 대해서도 비동기 호출을 할 수 있는 수단을 제공하는데, 다름 아닌 델리게이트가 그런 역할을 한다. 즉, 메서드를 델리게이트로 연결해 두면 이미 비동기 호출을 위한 기반이 마련된 것이나 다름없다.
using System.Text;
public class Calc
{
public static long Cumsum(int start, int end)
{
long sum = 0;
for(int i = start; i <= end; i++)
{
sum += i;
}
return sum;
}
}
class Program
{
public delegate long CalcMethod(int start, int end);
static void Main(string[] args)
{
CalcMethod calc = new CalcMethod(Calc.Cumsum);
//Delegate 타입의 BeginInvoke 메서드를 호출한다.
//이 때문에 Calc.Cumsum 메서드는 ThreadPool의 스레드에서 실행한다.
IAsyncResult ar = calc.BeginInvoke(1, 100, null, null);
//BeginInvoke로 반환받은 IAsyncResult 타입의 AsyncWaitHandle 속성은 EventWaitHandle 타입이다.
//AsyncWaitHandle 객체는 스레드 풀에서 실행된 Calc.Cumsum 메서드 수행이 완료될 때까지 현재 스레드를 대기시킨다.
ar.AsyncWaitHandle.WaitOne();
//Calc.Cumsum의 반환값을 얻기 위해 EndInvoke 메서드를 호출한다.
//반환값이 없어도 EndInvoke는 반드시 호출하는 것을 권장한다.
long result = calc.EndInvoke(ar);
Console.WriteLine(result);
}
}
델리게이트의 비동기 호출을 위한 메서드(BeginInvoke/EndInvoke) 지금의 닷넷 플랫폼에서는 지원을 안하는 듯함
using System;
using System.Threading.Tasks;
class Program
{
//.NET Core와 .NET 5 이상에서는 비동기 메서드 호출을 위해 Task와 async/await 패턴을 사용하는 것이 권장됩니다.
// 델리게이트 정의
delegate void CalcMethod(int start, int end);
static async Task Main(string[] args)
{
int start = 0;
int end = 100;
CalcMethod calc = new CalcMethod(PerformCalculation);
// Task를 사용한 비동기 호출
await Task.Run(() => calc(start, end));
Console.WriteLine("Calculation completed.");
}
static void PerformCalculation(int start, int end)
{
// 실제 계산 로직을 여기에 작성
Console.WriteLine($"Calculating from {start} to {end}");
}
}
닷넷 BCL에서 제공되는 클래스 중에 Begin/End 접두사가 있는 메서드와 함께 제공된다면 비동기 호출을 의미하는 것이며, 그에 대한 사용법은 이번 절에서 배운 Delegate 사용법에 준한다.(C# 5부터 async/await 구문이 제공되면서 현재는 Delegate를 이용한 비동기 호출은거의 사용하지 않게 되었다.)
#네트워크 통신
전화 통신 | 컴퓨터 통신 | 설명 |
전화기 | 네트워크 어댑터(Network Adapter) | 컴퓨터에 보통 LAN 카드라고 내장돼 있는 것이 네트워크 어댑터에 해당한다. |
전화번호 | IP 주소(IP Address) | IP 주소는 4바이트 숫자다. 000.000.000.000 (0~255) |
사람 이름 | 도메인 이름(Domain Name) | 4개의 숫자로 나눠서 IP 주소를 표기하는 것도 인간 입장에서는 쉽게 기억할 수 없다. 따라서 IP 주소마다 이름을 부여해서 관리한다. |
전화번호부 | 도메인 네임 서버(Domain Name Server) | 도메인 이름과 그에 대응되는 IP 주소 정보를 보관하고 있는 컴퓨터 |
컴퓨터 간의 통신에서 서로의 주소(address)를 아는 것과 함께 중요한 것이 어떤 절차를 거쳐 통신을 주고받을 것이냐에 대한 규칙을 정하는 것이다. 이를 프로토콜(protocol)이라고 하는데, 현재 인터넷에서 가장 많이 사용하는 프로토콜은 TCP/IP(Transmission Control Protocol/Internet Protocol)다.
#System.NetIPAddress
현재 널리사용되는 TCP/IP의 IP(Internet Protocol)는 4번째 버전에 해당하는 기술로서, 이를 줄여 IPv4(IP version 4)라고 한다. 표현 범위 42억 -> IPv6
개인 IP는 라우터가 할당해줌 반면 공용 IP는 인터넷 기관에 공식적으로 등록한 후 사용함
인터넷 서비스 업체(ISP: Internet Service Provider)에서는 미리 대량의 공용 IP를 보유하고 있는 상태다. 각 가정에 필요할 때마다 공용 IP를 대여해 주고 회수한다. 즉, 집 컴퓨터에 할당된 IP는 공용이긴 하지만 컴퓨터를 끄면 해당 IP는 ISP 업체에 회수되고 다시 켜면 또 다른 가용한 공용 IP가 할당된다. 만약 집에 액세스 포인트(AP: Access Point) 같은 공유 기기를 사용하고 있다면 ISP에서 제공받은 공용 IP는 AP에 할당되고, AP에 연결된 다른 장비는 모두 개인 IP를 갖게 한다.
공용 IP가 모두 소진되어 제한된 수의 장비에만 할당될 수 있지만 이처럼 다양한 내부 IP 활용 덕분에 IPv6의 도움 없이 IPv4가 여전히 살아남을 수 있다.
C#에서 IP는 System.Net.IPAddress 타입으로 표현된다.
IPv6 표현: 0000:0000:0000:0000:0000:0000:0000:0000 16바이트(128비트)로 확장, 2바이트씩 16진수로 표현하고 콜론으로 구분해서 나타냄. 중간에 0으로 채워져 있으면 2개의 콜론을 사용해 줄이는 것이 가능함 2001:0000:0db3 ... -> 2001::0db3; 0000:...0000:0001 -> ::0001
#포트
네트워크 통신은 일반적으로 '서비스를 열고 있는 측(server)'과 서비스에 접속하는 측(client)'으로 나뉜다. 이름하여 클라이언트(client)/서버(server) 구조라고 하며, 줄여서 C/S라고도 한다.
서버가 통신을 열었을 때 클라이언트는 서버를 구분해서 접속을 시도해야 한다. 여기서 서버란 실제로 TCP/IP 통신을 하는 '프로그램'을 의미하는데, 한 대의 컴퓨터에서 실행되는 서버 프로그램의 종류는 매우 다양할 수 있다.(웹 서버, 메일 서버)
TCP/IP 통신의 식별자는 IP 주소다. 문제는 IP 주소가 컴퓨터에 장착된 네트워크 어댑터는 식별해 주지만, 운영체제상에서 실행 중인 프로그램까지는 구분할 수 없다는 점이다. 즉, 여러 개의 프로그램이 TCP/IP를 사용한다면 운영체제 입장에서 네트워크 어댑터로 들어온 통신 데이터를 어디에 보내야 할지 판단할 수 없다. 만약 IP로만 구분해야 한다면 컴퓨터마다 1개의 프로그램만 TCP/IP 통신 서비스를 할 수 있게 제한하거나, 아니면 여러 개의 IP를 컴퓨터가 가져서 각 IP로 프로그램이 점유해 사용할 수 있게만 할 수 있다.
위와 같은 제약을 극복하기 위해 TCP/IP는 '포트(port)'라는 개념을 추가한다. 포트는 0 ~ 65535 범위에 해당하는 값이므로 BCL에서 별도의 타입으로 정의되진 않았고 단지 ushort(부호 없는 2바이트 정수)로 표현한다.
포트가 도입됨으로써 서버 측 프로그램에서는 IP와 함께 포트를 이용해 통신을 대기할 수 있다. 따라서 원하는 포트 번호를 미리 선점해서 통신을 대기해 두면 클라이언트 측에서는 그 번호에 해당하는 포트를 지정해 서버와 연결할 수 있다.
1개의 IP로 65535개의 TCP/IP 응용 프로그램이 실행될 수 있다.
포트 번호 중에는 이미 용도를 전 세계적으로 합의한 것도 있다. 예를 들어, 21번 포트는 FTP 서버, 25번 포트는 SMTP 서버, 80번 포트는 웹 서버라는 식으로 1024번까지 예약돼 있다. 때문에 일반적으로 1025 ~ 65535 범위의 포트 번호로 결정하는 것을 권장한다.
#System.Net.IPEndPoint
EndPoint는 '접점', '종점' 또는 '종단점' 등으로 해석할 수 있는데, TCP/IP 통신에서 접점이란 'IP 주소' + '포트'를 일컫으며, BCL에서는 이 정보를 묶는 단일 클래스로 IPEndPoint 타입을 제공한다.
//접점 정보 표기법: [IP주소]:[포트번호]
IPAddress ipAddr = IPAddress.Parse("192.168.1.10");
IPEndPoint endpoint = new IPEndPoint(ipAddr, 9000);
#System.Net.Dns
IPHostEntry entry = Dns.GetHostEntry("www.microsoft.com");
foreach(IPAddress ipAddress in entry.AddressList)
{
Console.WriteLine(ipAddress);
}
GetHostEntry 정적 메서드는 도메인 이름을 입력받으면 시스템에 설정된 도메인 네임 서버(DNS: Domain Name Server)로 해당 이름의 IP를 조회한다. 결과로 돌려 받은 IPHostEntry 타입은 도메인 이름에 설정된 IP 목록을 IPAddress 타입의 배열인 AddressList 속성으로 제공한다
하나의 도메인에 IP가 여러 개인 이유 집전화, 핸드폰, 회사 전화 등이 하나의 이름으로 묶인 경우 같음
부가적으로 윈도우 운영체제는 컴퓨터 이름을 자체적인 도메인 이름처럼 해석하는 기능을 제공한다. 따라서 사용 중인 컴퓨터의 이름을 GetHostEntry에 전달하면 실행 중인 컴퓨터에 할당된 IP 주소 목록을 얻을 수 있다.
//현재 컴퓨터에 할당된 IP 주소 출력
string myComputer = Dns.GetHostName();
Console.WriteLine("컴퓨터 이름: " + myComputer);
IPHostEntry entry = Dns.GetHostEntry(myComputer);
foreach(IPAddress ipAddress in entry.AddressList)
{
Console.WriteLine(ipAddress.AddressFamily + ": " + ipAddress);
}
도메인 이름을 사용할 때의 단 한 가지 단점이라면 DNS로부터 IP 주소를 조회해야 하기 때문에 그만큼 속도가 저하된다는 것이다. 이 때문에 윈도우 운영체제에는 내부적으로 한번 조회된 적이 있는 도메인명과 IP 주소는 일정 시간 동안 저장해 두는 기능 있다.
도메인 이름의 이런 특징은 1개의 도메인명에 N개의 IP가 묶은 경우 일종의 부하 분산(load balance) 역할을 하기도 한다.
#System.Net.Sockets.Socket
운영체제 TCP/IP 통신을 위해 소켓(socket)이라는 기능을 만들어 두고 있으며, 닷넷 응용 프로그램도 소켓을 이용해 다른 컴퓨터와 TCP/IP 통신을 할 수 있다.
public Socket(AddressFamily addressFamily, SocketType socketType,ProtocolType protocolType)
모든 인자가 enum 형식인데, 각 정의에 따르면 AddressFamily는 31개, SocketType은 6개, ProtocolType은 25개의 값을 갖는다.
//스트림 소켓 또는 TCP 소켓
Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//데이터그램 소켓 또는 UDP(User Datagram Protocol) 소켓
Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
*IPv6용 소켓을 생성하려면 첫 번째 인자에 AddressFamily.InterNetworkV6 값을 주면 된다.
어떤 유형의 소켓을 생성했느냐에 따라 이후의 프로그래밍 방식도 달라진다. 따라서 만들려는 프로그램에 적합한 방식의 소켓을 미리 정해놓고 사용해야 한다. 대부분의 인터넷 통신은 TCP 소켓을 사용
기준 | 소켓 | |
TCP | UDP | |
연결성 | 통신 전에 반드시 서버로 연결(연결 지향성: connection-oriented) | 연결되지 않고 동작 가능(비연결 지향성: connectionless) |
신뢰성 | 데이터를 보냈을 때 반드시 상대방은 받았다는 신호를 보내줌(신뢰성 보장) | 데이터를 보낸 측은 상대방이 정상적으로 데이터를 받았는지 알 수 없음 |
순서 | 상대방은 데이터를 보낸 순서대로 받게 됨 | 데이터를 보낸 순서와 상관없이 먼저 도착한 데이터를 받을 수 있음 |
속도 | 신뢰성 및 순서를 확보하기 위한 부가적인 통신이 필요하므로 UDP에 비해 다소 느림 | 부가적인 작업을 하지 않으므로 TCP보다 빠름 |
Socket 타입은 IDisposable을 상속 받았다. 따라서 소켓을 생성한 후 필요가 없어지면 반드시 자원을 해제해야 한다.
//소켓 자원 생성
Socket socket =
new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp);
//......[소켓을 사용해 통신]......
//반드시 소켓 자원 해제
socket.Close();
소켓이란? 종류, 통신 흐름, HTTP통신과의 차이 (velog.io)
#UDP 소켓
소켓 통신의 기본은 접속을 받아들이는 서버와 그에 연결하는 클라이언트를 작성하는 것이다. 서버 컴퓨터에는 여러 개의 어댑터와 여러 개의 IP가 묶여 있을 수 있다. 즉, 컴퓨터와 어댑터는 1:N 관계이고, 어댑터와 IP 주소도 1:N 관계다.
클라이언트 소켓은 데이터가 전송돼야 할 대상을 지정하기 위해 접점 정보(IP주소와 포트)가 필요하다. 따라서 서버 소켓은 컴퓨터에 할당된 IP 주소 중에서 어떤 것과 연결될지 결정해야 하고 이 과정을 바인딩(binding)이라 한다. 간단히 말해서 소켓이 특정 IP와 포트에 묶이면 바인딩이 성공했다고 볼 수 있다. 이렇게 바인딩되고 나면 다른 소켓에서는 절대로 동일한 접점 정보로 바인딩할 수 없다.
Socket socket = ......;
//소켓은 모든 IP에 대해 바인딩할 수 있는 방법을 제공한다
IPAddress ipAddress = IPAddress.Parse("0.0.0.0");
sicjet.Bind(endPoint);
//SocketName.ReceiveFrom(버퍼, ref 접점)
//SocketName.SendTo(버퍼, ref 접점)
127.0.0.1 - 프로그램을 실행 중인 컴퓨터의 IP주소를 의미함
->IPAddress.Loopback
#UDP 소켓의 특성
- 비연결성(Connectionless): UDP 클라이언트 측에서 명시적인 Connection 설정 과정이 필요 없다.
- 신뢰성 결여: 전달된 데이터가 상대방에게 반드시 도착한다는 보장이 없다.
- 순서 없음
- 최대 65,535바이트라는 한계: SendTo 메서드에 전달하는 바이트의 크기는 65535를 넘을 수 없다.좀 더 정확하게는 각종 데이터 패킷의 헤더로 인해 그 크기는 다소 줄어든다.
- 파편화(fragmentation): UDP를 이용해 많은 데이터를 보내는 것은 좋지 않은 선택이다. 이론상 64KB가 약 1000바이트 정도로 분할되어 전송될 수 있다. 그렇게 되면 64번의 데이터를 전송하게 되는데, 이 중 하나라도 중간에 패킷이 유실되면 수신 측의 네트워크 장치가 받은 63개의 패킷은 폐기되어 버린다. 즉, 한 번에 보내는 UDP 데이터의 양이 많을수록 데이터가 폐기될 확률이 더 높아진다.
- 메시지 중심(message-oriented): 송신 측에서 한 번의 SendTo 메서드 호출에 1000바이트의 데이터를 전송했다면 수신 측에서도 ReceiveFrom 메서드를 한 번 호출했을 때 1000바이트를 받는다. 즉, SendTo에 전달된 64KB 이내의 바이트 배열은 상대방에게 정상적으로 보내는 데 성공하기만 한다면 ReceiveFrom 메서드에서는 그 바이트 배열 데이터를 그대로 한 번에 받을 수 있다. 메시지 중심의 통신이란 이런 식으로 보내고 받는 메시지 경계(message boundary)가 지켜짐을 의미한다.
UDP 통신은 사용법이 간단한 대신 신뢰성의 결여 문제로 안정성을 확보하는 코드를 개발자가 직접 구현해야 한다.
#TCP 소켓
서버 소켓의 경우 다음과 같은 생명 주기(lifecycle)을 갖는다.
Bind 단계까지는 UDP 서버 소켓을 사용하는 방법과 유사하다. Stream 유형의 소켓을 생성한 후 TCP 통신을 위한 EndPoint(IP + port)으로 Bind 메서드를 호출하면 된다. TCP 역시 일단 고유한 endpotin로 바인딩되면 같은 운영체제에서 실행되는 어떠한 프로세스도 동일한 정보로 소켓 바인딩을 할 수 없다.
Socket srvSocket =
new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, 11200);
//바인딩이 완료된 TCP 서버 소켓은 Listen 메서드를 호출하면서 클라이언트로부터의 접속을 허용한다.
srvSocket.Bind(endPoint);
//클라이언트의 접속을 보관할 수 있는 큐의 길이를 나타냄 아래의 경우 최대 10개
srvSocket.Listen(10);
//보관된 클라이언트의 연결을 꺼내는 것은 Accept메서드를 호출함으로써 가능함
Socket clntSocket = srvSocket.Accept();
Tcp 서버용 소켓 인스턴스는 클라이언트와 직접 통신할 수 없고 오직 새로운 연결을 맺는 역할만 한다. 클라이언트와의 직접적인 통신은 서버 소켓의 Accept에서 반환된 소켓 인스턴스로만 할 수 있다.
데이터 송수신을 위해 UDP에서는 SendTo/ReceiveFrom을 사용했지만 TCP 통신에서는 반드시 Send/Receive 메서드를 사용해야 한다.
TCP 서버측 소켓 -> Accept, 클라이언트측 소켓 -> Connect 단계 추가
TCP 클라이언트 측에서 Connect를 호출하는 시점에 TCP 서버는 반드시 Listen을 호출한 상태여야만 한다. 그렇지 않으면 TCP 클라이언트의 Connect 호출은 예외를 일으키며 실패하고 만다.
#TCP 소켓의 특성
- 연결성(connection-oriented): TCP 통신은 서버측의 Listen/Accept와 클라이언트 측의 Connect를 이용해 반드시 연결이 이뤄진 다음 통신할 수 있다.
- 신뢰성: Send 메서드를 통해 수신 측에 데이터가 전달되면 수신 측은 내부적으로 그에 대한 확인(ACK) 신호를 송신 측에 전달한다. 따라서 TCP 통신은 데이터가 수신 측에 정상적으로 전달됐는지 알 수 있고, 이 과정에서 ACK 신호가 오지 않으면 자동으로 데이터를 재전송함으로써 신뢰성을 확보한다.
- 스트림 중심(stream-oriented): UDP에서 제공된 메시지 간의 경계가 없다. 예를 들어, 10,100 바이트의 데이터를 Send메서드를 이용해 송신하는 경우 내부적인 통신 상황에 따라 2048, 2048, 5904바이트 단위로 잘라서 전송될 수 있다. 따라서 1번의 Send 메서드를 이용해 실행됐음에도 수신하는 측은 여러 번 Receive 메서드를 호출해야만 모든 데이터를 받을 수 있다. 이렇게 메시지가 경계를 가지지 않고 전달되는 것을 스트림 방식이라 한다.
- 순서 보장: 데이터를 보낸 순서대로 수신 측에 전달된다.
#TCP 서버 개선 - 다중 스레드와 비동기 통신
I/O가 완료될 때까지 Send/Receive 메서드를 호출한 스레드는 블로킹되므로 서버측에서 Accept를 빠르게 처리할 수 없는 문제가 있다. Accept를 빠르게 처리하기 위해 서버 소켓이 Accept로 반환받은 클라이언트의 처리를 별도의 스레드에 맡겨서 처리하는 방법이 있다. (481-482)
소켓 통신은 운영체제에게는 I/O에 속하기 때문에 Socket 타입에는 Send/Receive 메서드에 대해 각각 BeginSend/EndSend-BeginReceive/EndReceive 비동기 메서드가 제공된다. -> 스레드를 통한 처리는 스레드가 늘어날수록 오버헤드 문제가 심해짐 이를 해결가능
비동기 호출을 사용하면 구현이 복잡해지므로 웬만한 고성능 TCP 서버를 구현하는 것이 아니라면 비동기 호출을 이용하기보다는 스레드와 클라이언트가 1:1로 대응되는 형식으로 만드는 방식이 선호된다.
컴퓨터 통신에는 예외적인 상황이 많으므로 예외 처리를 고려하자
#HTTP 통신
인터넷 익스플로러/크롬 등의 브라우저가 하는 역할은 '웹 서버'측에 'HTTP 프로토콜'을 사용해 데이터를 요청하고 받아와서 화면에 보여주는 것이다.
HTTP 통신은 TCP 서버/클라이언트의 한 사례다. 즉 웹 서버는 TCP 서버이고, 웹 브라우저는 TCP 클라이언트다. 일반적으로 HTTP 서버는 80 포트를 사용하도록 약속돼 있다. 따라서 그동안 방문한 웹 사이트가 있다면 이미 EndPoint 정보를 알고 있는 것이나 다름 없다. 접점을 알았으면 그다음으로 웹 서버와 통신하려면 보내고 받는(Send/Receive) 절차(protocol)를 알아야 한다.
HTTP 프로토콜의 기본은 '요청(request)'과 '응답(response)'이다. 접속한 클라이언트 측에서 먼저 요청을 보내면(send), 서버는 요청으로 받은 내용을 분석해 어떤 데이터를 넘겨줘야 할지를 판단하고 클라이언트 측으로 응답(response)을 보낸다.
URL(Uniform Resource Locator: 유일한 자원 지시자)
//HTTP 요청은 다음과 같은 형식을 띔 개행문자 == \r\n
GET / HTTP/1.0(개행문자)
HOST:www.microsoft.com(개행문자)
(개행문자)
HTTP 프로토콜의 정의에 따르면 기본 문서(default document)를 요청하는 / 경로가 GET 문자열 다음으로 설정된다. 웹 브라우저의 이런 요청에 대응해서 서버는 / 경로에 해당하는 데이터를 보내준다.
//TCP 소켓을 이용한 HTTP 통신
using System.Net.Sockets;
using System.Net;
using System.Text;
class Program
{
static void Main(string[] args)
{
Socket socket =
new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPAddress ipAddr = Dns.GetHostEntry("www.microsoft.com").AddressList[0];
EndPoint serverEP = new IPEndPoint(ipAddr, 80);
socket.Connect(serverEP);
//HTTP 프로토콜 통신
string request = "GET / HTTP/1.0\r\nHost: www.microsoft.com\r\n\r\n";
byte[] sendBuffer = Encoding.UTF8.GetBytes(request);
//Microsoft 웹 서버로 HTTP 요청을 전송
socket.Send(sendBuffer);
//HTTP 요청이 전달됐으므로 Microsoft 웹 서버로부터 응답을 수신
MemoryStream ms = new MemoryStream();
while (true)
{
byte[] rcvBuffer = new byte[4096];
int nRecv = socket.Receive(rcvBuffer);
if(nRecv == 0)
{
//서버 측으로부터 더 이상 받을 데이터가 없으면 0을 반환
break;
}
ms.Write(rcvBuffer, 0, nRecv);
}
socket.Close();
string response = Encoding.UTF8.GetString(ms.GetBuffer(), 0 , (int)ms.Length);
Console.WriteLine(response);
//서버 측에서 받은 HTML 데이터를 파일로 저장
File.WriteAllText("page.html", response);
}
}
HTTP 프로토콜은 요청과 응답에서 2개의 개행 문자(\r\n)를 구분자로 해서 HTTP 헤더(Header)와 본문(Body)의 내용을 구성한다.
웹 브라우저란 단지 사용자가 주소란에 입력한 정보를 기반으로 HTTP 요청을 만들어 TCP 소켓으로 전송하고 그 응답으로 받은 텍스트 중에서 HTTP 본문에 해당하는 내용을 화에 출력하는 프로그램이다.
//TCP 소켓으로 구현한 HTTP 서버
using System.Net.Sockets;
using System.Net;
using System.Text;
class Program
{
static void Main(string[] args)
{
using (Socket srvSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
{
//127.0.0.1 주소가 컴퓨터의 루프백 주소인 것처럼 localhost는 컴퓨터의 루프백 호스트명에 해당한다
Console.WriteLine("http://localhost:8000 으로 방문해 보세요.");
IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, 8000);
srvSocket.Bind(endPoint);
srvSocket.Listen(10);
while (true)
{
Socket clntSocket = srvSocket.Accept();
ThreadPool.QueueUserWorkItem(httpProcessFunc, clntSocket);
}
}
}
private static void httpProcessFunc(object state)
{
Socket socket = state as Socket;
//......[HTTP 프로토콜 통신에 따라 Send/Receive]......
byte[] reqBuf = new byte[4096];
socket.Receive(reqBuf);
string header =
"HTTP/1.0 200 OK\nContent-Type: text/html; charset=UTF-8\r\n\r\n";
string body =
"<html><body><mark>테스트 HTML</HTML> 웹 페이지입니다.</body></html>";
byte[] respBuf = Encoding.UTF8.GetBytes(header + body);
socket.Send(respBuf);
socket.Close();
}
}
이 밖에도 '프로토콜'이라 불리는 것들이 많다. 메일을 전송하는 SMTP 프로토콜, 메일을 수신하는 POP3 프로토콜, 파일을 송수신하는 FTP 프로토콜 등은 모두 HTTP 프로토콜과 유사한 방식으로 접근하면 된다. 즉, 기본적인 TCP 서버/클라이언트 구조는 같고 단지 내부적으로 Send/Receive를 수행하는 부분만 각자 고유한 프로토콜에 맞게 구현했을 뿐이다.
#System.Net.Http.HttpClient
BCL에는 HTTP 통신을 좀 더 쉽게 할 수 있는 HttpClient 타입이 제공된다.
//위에 TCP 소켓을 이용한 HTTP 통신 예제의 작업을 다음과 같이 간단하게 처리 가능
class Program
{
static HttpClient _client = new HttpClient();
static void Main(string[] args)
{
//GetStringAsync는 비동기 메서드이기 때문에 보통 이렇게 구현하지 않고
//C#5.0부터 추가된 await 예악어를 이용한 호출을 한다. 10.2 비동기 호출 참고
string text = _client.GetStringAsync("http://www.naver.com:80").Result;
Console.WriteLine(text);
}
}
위에서 했던 예제와 다르게 HTTP 헤더 영역이 제거되고 순수하게 HTTP 본문만 포함돼 있다. 이렇게 HttpClient 객체는 HTTP 통신과 관련된 요청/응답 데이터를 적절하게 해석하는 역할까지 대행하므로 TCP 소켓을 직접 사용해서 통신해야 하는 불편함이 줄어든다.
#데이터베이스
``대충 보고 넘김 나중에 데베 들을 때 다시보기''
RDB: rational database 관계형 데이터베이스
테이블이야말로 실질적으로 데이터를 담을 수 있는 컨테이너(container)다. 데이터베이스는 단지 1개 이상의 테이블을 담을 수 있는 컨테이너이고, 그 데이터베이스를 외부 프로그램에 서비스하는 것이 바로 마이크로소프트 SQL 서버 같은 관계형 데이터베이스 소프트웨어다.
#SQL 쿼리
모든 관계형 데이터베이스 소프트웨어는 데이터를 조작하는 방법으로 SQL 쿼리(query)라는 표준 언어를 지원한다.
- INSERT: 자료 생성
- SELECT: 자료 선택
- UPDATE: 자료 갱신
- DELETE: 자료 제거
#데이터 조작(CRUD)
- Create(생성): 테이블에 데이터를 생성한다.
- Retrieve(조회): 테이블에 있는 데이터를 조회한다.
- Update(갱신): 테이블에 저장돼 있는 기존 데이터를 변경한다.
- Delete(삭제): 테이블에 저장돼 있는 기존 데이터를 삭제한다.
#ADO.NET 데이터 제공자
- 보안 취약: SQL 문법에 해당하는 문자열을 사용자가 입력하는 경우 수행되는 쿼리가 의도하지 않는 결과를 낳을 수 있다. 이를 'SQL 주입(injection)'이라고 하며 심각한 보안 결함에 해당한다.
- 서버 측의 쿼리 수행 성능 저하:SQL 서버는 수행되는 쿼리를 내부적인 컴파일 과정을 거쳐 실행 계획(execution plan)을 생성한다. 그리고 한 번 수행된 쿼리의 경우 실행 계획을 캐싱해서 다음에 동일한 쿼리가 수행되면 빠르게 수행할 수 있게 한다. 하지만 하나의 단일 쿼리 문으로 수행되는 경우 도일한 쿼리가 발생할 확률이 낮아지므로 캐시로 인한 성능이 좋지 않다.
-> 이런 문제점은 매개변수화된 쿼리(parameterized query)를 사용하면 해결된다. 즉, 실행할 쿼리 문 중에서 변수처럼 사용될 영역을 별도로 구분해서 쿼리를 전달하는 것이다.
#데이터 컨테이너
데이터베이스의 표현 자체는 매우 단순하다. n개의 칼럼이 모여 하나의 테이블을 이루고, 그러한 테이블 n개가 모여 데이터베이스를 이루는 것이다.
#데이터베이스 트랜잭션
트랜잭션(transaction)은 간단히 이해해서 다수의 쿼리 실행이 모두 실패하거나 모두 성공하는 논리적 단위이다.
트랜잭션을 사용할 경우 보장되는 특성(ACID)
- 원자성(Atomicity): 트랜잭션과 관련된 작업이 모두 수행되거나 수행되지 않음을 보장한다.
- 일관성(Consistency): 트랜잭션이 실행을 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 의미한다.
- 고립성(Isolation): 트랜잭션을 수행할 때 다른 트랜잭션의 연산 작업이 끼어들지 못하게 보장하는것을 의미한다. 이것은 트랜잭션 밖에 있는 어떤 연산도 중간 단계의 데이터를 볼 수 없음을 의미한다.
- 지속성(Durability): 성공적으로 수행된 트랜잭션은 영원히 반영돼야 함을 의미한다. 또한 모든 트랜잭션은 로그가 남아 시스템 장애가 발생하기 전의 상태로 되돌릴 수 있다. 예를 들어, 데이터베이스의 내용을 실수로 삭제했다면 트랜잭션 로그를 통해 다시 복원할 수 있다.
#리플렉션
닷넷 응용 프로그램의 어셈블리 파일 안에는 메타데이터(metadata)가 있다. BCL에서 제공하는 리플렉션(reflection) 관련 클래스를 이용하면 메타데이터 정보를 얻는 것이 가능하다.
닷넷 프로세스는 운영체제에서 EXE 프로세스로 실행되고 그 내부에 CLR에 의해 '응용 프로그램 도메인(AppDomain: Application Domain)'이라는 구획이 생김. AppDomain은 CLR이 구현한 내부적인 격리 공간으로써 응용 프로그램마다 단 1개의 AppDomain이 존재한다.
///매니페스트를 포함하고 있지 않은 모듈은 보통 확장자가 netmodule이고, 매니페스트를 포함하는 경우 확장자는 DLL or EXE; 다른 사람이 만든 어셈블리에 구현된 코드를 사용하고 싶다면 매니페스트가 포함된 모듈 및 그와 관련된 모든 모듈을 함께 가지고 있어야 한다./// 어셈블리는 모듈의 집합
//Assembly -> module -> type 계층적 접근
using System.Reflection;
class Program
{
static void Main(string[] args)
{
AppDomain currentDomain = AppDomain.CurrentDomain;
Console.WriteLine("Current Domain Name: " + currentDomain.FriendlyName);
foreach(Assembly asm in currentDomain.GetAssemblies())
{
Console.WriteLine("\n" + asm.FullName);
foreach(Module module in asm.GetModules()) //.dll or .exe
{
Console.WriteLine("\n " + module.Name);
foreach(Type type in module.GetTypes())
{
Console.WriteLine(" " + type.FullName);
}
}
}
}
}
타입을 열거하는 것은 모듈 단위로도 가능하지만 어셈블리 레벨에서 열람하는 것도 가능하다. 일반적으로 어셈블리 내에 모듈이 한 개만 포함돼 있는 경우가 대부분이므로 현실적으로는 어셈블리에서 직접 타입을 구하는 방법이 더 선호된다.
//타입은 각종 멤버(메서드, 프로퍼티, 이벤트, 필드, ......)를 가지므로
//Type.GetMembers 메서드를 이용해 열람할 수 있다.
foreach(Type type in asm.GetTypes())
{
Console.WriteLine(" " + type.FullName);
foreach(MemberInfo memberInfo in type.GetMembers())
{
Console.WriteLine(" " + memberInfo.Name);
}
}
C# 코드가 빌드되어 어셈블리에 포함되는 경우 그에 대한 모든정보를 조회할 수 있는 기술을 일컬어 리플렉션이라 한다.
#AppDomain과 Assembly
AppDomain은 EXE 프로세스 내에서 CLR에 의해 구현된 격리 공간이라고 설명했다. 현재 스레드가 실행 중인 어셈블리가 속한 AppDomain 인스턴스는 CurrentDomain 정적 속성을 이용해 접근할 수 있다.
AppDomain currentDomain = AppDomain.CurrentDomain;
AppDomain 내에 로드되는 어셈블리들은 보통 참조한 라이브러리로 구성되지만 원한다면 직접 로드하는 것도 가능하다. AppDomain 내에 어셈블리를 로드하는 간단한 방법은 CreateInstanceFrom 메서드를 이용해 어셈블리 파일의 경로와 최초 생성될 객체의 타입명을 지정하는 것이다.
클래스의 완전한 이름(FQDN: 네임스페이스 경로까지 포함한 이름)
C/C++로 윈도우 프로그램을 만들어 본 경험이 있는 개발자는 DLL 파일을 LoadLibrary API를 이용해 프로세스에 로드했다가 FreeLibrary API를 이용해 메모리부터 해제할 수 있다는 사실을 알고 있을 것이다. 하지만 닷넷 응용 프로그램의 경우에는 한번 로드된 어셈블리는 절대로 다시 내릴 수 없다.
using System.Reflection;
using System.Runtime.Remoting;
class Program
{
static void Main(string[] args)
{
AppDomain currentAppDomain = AppDomain.CurrentDomain;
string dllPath =
@"C:\Users\ndhph\Desktop\code\C#_study\C#_practice\ClassLibrary1\bin\Debug\ClassLibrary1.dll";
// ObjectHandle을 사용하여 인스턴스 생성
ObjectHandle objHandle = currentAppDomain.CreateInstanceFrom(dllPath, "ClassLibrary1.Class1");
// 원본 개체 가져오기
object obj = objHandle.Unwrap();
// MethodInfo를 사용하여 Display 메서드 가져오기
MethodInfo displayMethod = obj.GetType().GetMethod("Display");
// Display 메서드 호출
displayMethod.Invoke(obj, null);
}
}
#Type과 리플렉션
리플렉션으로 메타데이터를 조회만 할 수 있는 것은 아니다. 타입을 생성할 수 있고, 그 타입에 정의된 메서드를 호출할 수 있으며, 심지어 필드/프로퍼티의 값을 바꾸는 것도 가능하다.
using System.Reflection;
namespace ConsoleApp1;
public class SystemInfo
{
bool _is64Bit;
public SystemInfo()
{
_is64Bit = Environment.Is64BitOperatingSystem;
Console.WriteLine("SystemInfo creadted.");
}
public void WriteInfo()
{
Console.WriteLine("OS == {0}bits", (_is64Bit == true) ? "64" : "32");
}
}
class Program
{
static void Main(string[] args)
{
Type systemInfoType = Type.GetType("ConsoleApp1.SystemInfo");
//Activator 타입의 CreateInstance 정적 메서드는 Type 정보만 가지고 해당 객체를 생성할 수 있게 해준다.
object objInstnce = Activator.CreateInstance(systemInfoType);
//또는 타입의 생성자를 리플렉션으로 구해서 직접 호출하는 것도 가능하다.
//GetConstructor 메서드는 Type.EmptyTypes 인자를 받는 경우 지정된 타입의 기본 생성자를 반환함
ConstructorInfo ctorInfo = systemInfoType.GetConstructor(Type.EmptyTypes);
//ConstructorInfo 타입은 Invoke 메서드를 제공하는데, 이름이 의미하는 것처럼 생성자를 호출(invocation)함으로써 객체를 만든다.
object objInstance = ctorInfo.Invoke(null);
MethodInfo methodInfo = systemInfoType.GetMethod("WriteInfo");\
//Invoke 첫 번째 인자는 호출될 객체의 인스턴스, 두 번째 인자에는 해당 메서드에 필요한 인자 목록
methodInfo.Invoke(objInstance, null);
}
}
[강력하게 결합된 코드(tightly coupling)]
SystemInfo sysInfo = new SystemInfo();
sysInfo.WriteInfo();
[리플렉션을 사용해 느슨하게 결합된 코드(loosely coupling)]
Type systemInfoType = Type.GetType("ConsoleApp1.SystemInfo");
object objInstance = Activator.CreateInstance(systemInfoType);
MethodInfo methodInfo = systemInfoType.GetMethod("WriteInfo");
methodInfo.Invoke(objInstance, null);
리플렉션을 이용한 타입 접근은 심지어 OOP의 캡슐화마저도 무시할 수 있는 위력을 발휘한다. 예를 들어, 일반적인 C# 코드로는 SystemInfo 타입에 정의된 _is64Bit 필드에 접근할 수 없지만 리플렉션을 이용하면 가능하다.
//private 속성 접근
FieldInfo fieldInfo = systemInfoType.GetField("_is64Bit", BindingFlags.NonPublic
| BindingFlags.Instance);
//기존 값을 구하고,
object oldValue = fieldInfo.GetValue(objInstance);
//새로운 값을 쓴다.
fieldInfo.SetValue(objInstance, !Environment.Is64BitOperatingSystem);
//확인을 위해 WriteInfo 메서드 호출
methodInfo.Invoke(objInstance, null);
리플렉션을 이용해 타입을 다루는 코드에서 한 가지 특이사항이 있는데, 바로 해당 타입이 가진 코드의 멤버를 C# 코드에서 직접 접근하지 않았다는 점이다.
[리플렉션을 사용하지 않은 코드 접근 - 멤버에 직접적으로 접근]
SystemInfo sysInfo = new SystemInfo();
sysInfo.WriteInfo();
[리플렉션을 사용한 코드 접근 - 멤버에 간접적으로 접근]
Type systemInfoType = Type.GetType("ConsoleApp1.SystemInfo");
object objInstnce = Activator.CreateInstance(systemInfoType);
MethodInfo methodInfo = systemInfoType.GetMethod("WriteInfo");
methodInfo.Invoke(objInstance, null);
GetType, GetMethod, GetField의 인자는 모두 문자열의 이름이 사용됐기 때문에 사실상 리플렉션을 사용한 C# 코드는 컴파일러 입장에서 봤을 때 SystemInfo 타입을 몰라도 되는 셈이다.
#리플렉션을 이용한 확장 모듈(Plug-in, Add-on) 구현
보통 플러그인을 구현한 소프트웨어의 동작 방식은 다음과 같다.
- EXE 프로그램이 실행되는 경로 아래에 확장 모듈을 담도록 약속된 폴더가 있는지 확인한다.
- 해당 폴더가 있다면 그 안에 DLL 파일이 있는지 검사하고 로드한다.
- DLL이 로드됐으면 사전에 약속된 조건을 만족하는 타입이 있는지 살펴본다.
- 조건에 부합하는 타입이 있다면 생성하고, 역시 사전에 약속된 메서드를 실행한다.
따라서 플러그인을 이용할 수 있는 응용 프로그램을 만드는 개발자는 위의 순서에 나열된 몇몇 조건을 확정짓고, 그 규칙을 공개하면 된다.
예시
- 확장 모듈이 담길 폴더명: EXE 하위의 plugin
- 플러그인 타입 조건: 타입에 "PluginAttribute"라는 이름의 특성(Attribute)이 부여돼 있어야 한다.
- 호출될 메서드: 메서드에는 "StartupAttribute"라는 이름의 특성(Attribute)이 부여돼 있어야 한다. 또한 입력 인자도 없고, 반환값도 없어야 한다.
using System.Reflection;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
string pluginFolder = @"C:\NDH\ConsoleApp\reflectionTester\reflectionTester\plugin";
if(Directory.Exists(pluginFolder))
{
ProcessPlugIn(pluginFolder);
}
}
private static void ProcessPlugIn(string rootPath)
{
foreach(string dllPath in Directory.EnumerateFiles(rootPath, "*.dll"))
{
//확장 모듈을 현재의 AppDomain에 로드
Assembly pluginDll = Assembly.LoadFrom(dllPath);
//DLL에 포함된 모든 파일을 열거
Type entryType = FindEntryType(pluginDll);
if(entryType == null)
{
continue;
}
//타입에 해당하는 객체를 생성하고,
object instance = Activator.CreateInstance(entryType);
//약속된 메서드를 구하고,
MethodInfo entryMethod = FindStartupMethod(entryType);
if(entryMethod == null)
{
continue;
}
//메서드를 호출한다.
entryMethod.Invoke(instance, null);
}
}
private static MethodInfo FindStartupMethod(Type entryType)
{
foreach(MethodInfo methodInfo in entryType.GetMethods())
{
foreach(object objAttribute in methodInfo.GetCustomAttributes(false))
{
if(objAttribute.GetType().Name == "StartupAttribute")
{
return methodInfo;
}
}
}
return null;
}
private static Type FindEntryType(Assembly pluginDll)
{
foreach(Type type in pluginDll.GetTypes())
{
foreach(object objAttr in type.GetCustomAttributes(false))
{
if(objAttr.GetType().Name == "PluginAttribute")
{
return type;
}
}
}
return null;
}
}
}
//DLL 소스코드
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ClassLibrary1
{//특성 클래스에 매개변수가 포함된 생성자를 추가할 수도 있다.
class PluginAttribute : System.Attribute
{
string name;
int version { get { return version; } set { version = value; } }
public PluginAttribute(string name)
{
this.name = name;
}
}
class StartupAttribute : System.Attribute
{
string name;
int version { get { return version; } set { version = value; } }
public StartupAttribute(string name)
{
this.name = name;
}
}
[PluginAttribute("ex")]
public class SystemInfo
{
[StartupAttribute("exM")]
public void method()
{
Console.WriteLine("method executed");
}
}
}
플러그인을 사용하면 DLL 파일을 변경하고 빌드해도 원래 응용 프로그램은 빌드하지 않고도 새로운 코드가 반영됨
플러그인은 해당 코드가 '컴파일 시점'에 서로에 대한 코드 정보가 없어도 만들 수 있다는 장점이 있다. 이렇게 개발되는 유형을 일컬어 '느슨한 결합'이라 하고, 직접 대상을 참조해 사용하는 것을 '강력한 결합'이라 한다. 과거에는 DLL 간의 느슨한 결합은 속도 저하라는 이유로 기피됐지만 근래에는 하드웨어의 발전과 함께 유연한 구조(flexible architecture)를 지향하는 분위기로 인해 권장되는 추세다. 현업에서 많이 사용하는 spring.NET 프레임워크나 MEF 프레임워크, Unity 컨테이너 같은 유명한 라이브러리들도 결국 느슨한 결합을 위한 도구이며, 이들 모두 내부적으로는 리플렉션을 이용해 구현돼 있다.
추후 프레임워크 제작에 관심이 있다면필수적으로 리플렉션을 알아야함
#기타
닷넷 응용 프로그램에서 사용되는 몇 가지 부수적인 성격의 유용한 클래스
#윈도우 레지스트리
C/C++ 시절의 윈도우 응용 프로그램과는 달리 XCopy 배포가 선호되는 닷넷 응용 프로그램의 경우 레지스트리를 사용하는 것을 그다지 선호하지 않는다. 하지만 레지스트리에는 윈도우의 많은 정보가 저자오대 있으므로 때로는 접근해야 할 명백한 사유가 발생하곤 한다. 이럴 때 Win32 API를 직접 사용하기보다는 Microsoft.Win32 네임스페이스에서 제공되는 Registry, RegistryKey 타입을 이용하면 간단하게 코드를 작성할 수 있다.
string regPath = @"HARDWARE\DESCRIPTION\System\BIOS";
using (RegistryKey systemKey = Registry.LocalMachine.OpenSubKey(regPath))
{
string biosDate = (string)systemKey.GetValue("BIOSReleaseDate");
string biosMaker = (string)systemKey.GetValue("BIOSVendor");
Console.WriteLine("BIOS 날짜: " + biosDate);
Console.WriteLine("BIOS 제조사: " + biosMaker);
}
#Registry 루트 경로에 대응되는 정적 속성
Registry 정적 속성 | 대응되는 레지스트리 루트 경로 |
ClassesRoot | HKEY_CLASSES_ROOT |
CurrentUser | HKEY_CURRENT_USER |
LocalMachine | HKEY_LOCAL_MACHINE |
Users | HKEY_USERS |
CurrentConfig | HKEY_CURRENT_CONFIG |
OpenSubKey 메서드를 이용해 키의 경로에 해당하는 Registry 인스턴스를 얻었으면 Get-Value 메서드를 이용해 값을 가져올 수 있다.GetValue 자체는 object 타입으로 통합해 반환한다.
#레즈스트리 값의 타입과 대응되는 C# 타입
Registry 값 타입 | 대응되는 C# 타입 |
REG_SZ | string |
REG_BINARY | byte [] |
REG_DWORD | int |
REG_QWORD | long |
REG_MULTI_SZ | string [] |
//쓰기 작업을 하려면 OpenSubKey 메서드의 두 번째 인자에 true 값을 지정하면 됨
string regPath = @"HARDWARE\DESCRIPTION\System\BIOS";
//관리자 권한 필요
using (RegistryKey regKey = Registry.LocalMachine.OpenSubKey(regPath, true))
{
regKey.SetValue("TestValue1", 5); //REG_DWORD로 기록됨
regKey.SetValue("TestValue2", "Test"); //REG_SZ로 기록됨
}
#BigInteger
C#에서 표현 가능한 최대 정수형은 8바이트(2^64) long 형으로(닷넷 7부터는 Int128 사용 가능) -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 범위의 값을 갖는다. 일반적인 환경에서는 이 정도 범위로 충분하겠지만, 암호화 같은 분야에서는 천문학적인 숫자를 다루므로 다른 바업을 찾아야 한다. BCL에서는 이 요구에 맞춰 BigInteger 구조체 타입을 추가했고 사용법도 일반적인 int/long 형과 유사하다.
BigInteger int1 = BigInteger.Parse("12345678901234567890");
BigInteger int2 = BigInteger.Parse("98765432109876543210");
Console.WriteLine(int1 + int2);
C# 코드에서 사용되는 숫자형 리터럴로는 여전히 64비트로 제한돼 있기 때문에 BigInteger 타입을 초기화하려면 문자열을 사용해야함. 만약 long 형 범위의 숫자라면 그대로 대입 가능
#IntPtr
IntPtr은 정수형 포인터(integer pointer)를 의미하는 값 형식의 타입이다. 포인터는 메모리 주솟값을 보관하는 곳이므르 32비트 프로그램에서는 2^32 주소 영역을 지정할 수 있어야 하고, 64비트 프로그램은 2^64 주소 영역을 지정할 수 있어야 한다. 이 때문에 IntPtr 자료형은 32 비트 프로그램은 4바이트 64 비트 프로그램은 8 바이트로 동작하는 특징이 있다.
IntPtr 타입은 메모리 주소를 가리키는 것 외에 윈도우 운영체제의 핸들(HANDLE)값을 보관하는 용도로도 쓰인다.
핸들은 운영체제가 특정 자원에 대한 식별자(identifier)로서 보관하는 값인데, 일례로 파일이 좋은 예다. 닷넷 BCL에서더ㅗ
FileStream에서 핸들 값을 알 수 있는 속성이 제공된다.(fs.Handle)
Win32API를 호출하거나 기존 C?C++로 작성된 프로그램과 상호 연동해야 할 때 사용함.
'개인공부용1 > programming language' 카테고리의 다른 글
Do it! C언어 입문 (0) | 2024.01.28 |
---|