흔한 덕후의 잡동사니
C# 기술 면접 질문 정리 01 본문
기술 면접을 준비하며, 내가 알고 있는 지식을 바탕으로 관련된 문제들을 정리해보았다. 이 답변들은 반드시 정답이 아닐 수 있으며, 면접관이 기대하는 방향과 다를 수도 있다. 참고할 경우 이러한 점을 감안하고 읽는 것이 좋다.
문제
1. 객체란 무엇인가요? 클래스와 어떤 연관이 있나요?
객체(Object)는 클래스(Class)의 설계도를 바탕으로 실제로 메모리에 생성된 구체적인 실체이다. 클래스가 데이터와 행위를 정의하는 일종의 틀이라면, 객체는 이 틀을 이용하여 만들어진 실체로서 상태(데이터)와 행동(메서드)을 가진다.
예를 들자면 다음과 같다.
#include <iostream>
#include <string>
// 클래스 정의
class Player {
public:
std::string name;
int hp;
// 생성자
Player(const std::string& playerName, int health)
: name(playerName), hp(health) {}
// 멤버 함수
void PrintStatus() {
std::cout << "이름: " << name << ", 체력: " << hp << std::endl;
}
};
int main() {
// 동적 할당으로 객체 생성
Player* pPlayer = new Player("Knight", 100);
// 객체 사용
pPlayer->PrintStatus();
// 메모리 해제
delete pPlayer;
return 0;
}
이 코드는 `new` 연산자를 사용해 운영체제로부터 클래스 크기만큼의 메모리를 힙 영역에 동적으로 할당하고, 해당 메모리 공간에 생성자를 호출하여 객체를 초기화한다.
이렇게 힙 영역에 실질적으로 메모리가 할당되고, 내부 데이터가 구성된 메모리 블록을 객체(object)라고 부른다. 즉, 객체는 메모리 상에 실존하는 데이터 덩어리이며, 클래스에 정의된 멤버 변수들과 함수 포인터(가상 함수 테이블 등)가 실제로 메모리에 배치된다.
반면, 클래스(class)는 코드 영역에 존재하는 설계도에 불과하다. 클래스 자체는 메모리를 차지하지 않으며, 객체가 생성되기 전까지는 메모리 상에 실체가 없다.
따라서,
- 클래스: 메모리에는 정의 정보만 존재 (메모리 영역 : code)
- 객체: 클래스 정의를 바탕으로 실제 메모리에 올라간 인스턴스 (메모리 영역 : data, bss, heap, stack)
이러한 관점에서 객체는 클래스의 정의를 따라 메모리 상에 실체화된 구조체라고 이해할 수 있다.
2. 생성자에 대해 간단하게 설명해주세요.
간단하게 설명하자면 다음과 같다.
생성자(Constructor)는 객체가 생성될 때 자동으로 호출되는 특별한 메서드이다. 객체의 초기 상태를 설정하거나 초기화 작업을 수행하는 역할을 하며, 클래스 이름과 동일한 이름을 가지고 반환값은 없다.
조금 더 들어가면 다음과 같다.

다음과 같은 일이 내부적으로 호출된다.
[ call operator new (0EC11A4h) ]
[ call Player::Player (0EC13ACh) ]
컴파일러가 new를 만나는 순간 operator new()를 호출하고, 그 주소에 대해 Player::Player를 호출하는 코드를 자동으로 삽입한다.
이는 생성자도 결국엔 멤버 함수일 뿐이며, this 포인터를 인자로 받는 일반 함수 호출로 처리된다는 뜻이다.
3. 접근제한자란 무엇이며, 각각 어떤 차이가 있는지 비교해서 설명해주세요.
접근제한자(Access Modifier)는 클래스의 멤버(변수, 메서드 등)에 대한 접근 범위를 제한하는 키워드이다.
public | 어디서든 접근 가능 | 모든 외부 클래스에서 접근 가능 |
protected | 상속받은 클래스에서만 접근 가능 | 자식 클래스에서만 접근 가능 |
private | 해당 클래스 내부에서만 접근 가능 | 클래스 내부에서만 접근 가능 |
C#에는 internal 접근 제한자가 존재한다. 이는 동일 어셈블리 내에서만 접근이 가능하다는 의미이다.
여기서 어셈블리란 .NET에서 컴파일된 결과물인 .dll 또는 .exe 파일을 의미하며, 일반적으로 하나의 Visual Studio 프로젝트가 하나의 어셈블리가 된다.
어셈블리는 .NET에서 배포 단위이자 보안 단위, 접근 제어 단위로 사용된다. internal은 이 어셈블리의 경계 안에서만 유효하다. 즉, 해당 멤버는 같은 .dll 또는 .exe 파일로 빌드된 코드에서만 접근할 수 있고, 다른 어셈블리에서는 접근이 불가능하다.
어셈블리는 다음과 같은 과정을 통해 생성된다. .cs 파일은 C# 컴파일러(csc)에 의해 컴파일되고, 중간 언어인 IL(Intermediate Language) 코드가 생성된다. 이 IL 코드와 함께 클래스, 메서드, 필드, 접근 제한자 등의 정보가 메타데이터로 기록된다. 이 둘은 함께 하나의 .dll 또는 .exe 파일로 패키징된다. 이때 생성되는 메타데이터는 Unity에서 사용하는 .meta 파일과는 전혀 관련이 없다.
internal의 접근 제한이 어떻게 동작하는지는 컴파일 시점과 런타임 시점을 나누어 이해할 수 있다.
컴파일 시점에는 C# 컴파일러가 internal 멤버에 대해 메타데이터 테이블에 "assembly-level" 접근 제한으로 기록한다. IL 코드로 보면 해당 메서드는 .method assembly와 같이 정의되어 있으며, 이는 internal을 의미한다.
런타임이나 리플렉션을 통해 접근할 경우, CLR(Common Language Runtime)은 메타데이터를 읽고 해당 접근 요청이 같은 어셈블리에서 발생한 것인지를 확인한다. 만약 다른 어셈블리라면 접근은 차단된다. 정적 코드에서는 컴파일 오류가 발생하고, 리플렉션에서는 예외가 발생하거나 접근이 제한된다.
결론적으로 internal은 .NET 어셈블리 경계를 기준으로 접근을 제한하는 키워드이며, 이 제한은 메타데이터에 기록된 정보에 기반해 컴파일러와 CLR에 의해 적용된다.
4. static 한정자에 대해 설명해주세요.
static 한정자는 특정 변수나 메서드를 객체 인스턴스가 아닌 클래스 단위로 메모리에 할당하도록 지정하는 키워드이다. 일반적으로 지역 변수는 스택에 저장되고, 전역 변수는 데이터 또는 BSS 영역에 저장되는데, static은 지역 변수의 수명과 저장 위치를 전역 변수처럼 바꾼다.
이 개념을 이해하기 위해서는 프로그램이 사용하는 메모리 구조를 먼저 이해할 필요가 있다. 일반적으로 실행 중인 프로그램은 코드 영역, 데이터 영역, BSS 영역, 힙, 스택으로 나뉜다. 이 중 데이터 영역은 초기화된 전역 변수 및 정적 변수가 저장되며, BSS 영역은 초기화되지 않은 전역 변수 및 정적 변수가 저장된다. 두 영역 모두 프로그램이 시작될 때 메모리에 올라가고, 종료될 때까지 유지된다.
정적 변수는 이러한 정적 메모리 영역에 할당되며, 프로그램 실행 중 단 한 번만 초기화되고 이후에도 계속 유지된다. 함수 내에서 선언된 static 변수의 경우에도 스코프는 함수 내부로 제한되지만, 할당되는 메모리는 BSS나 데이터 영역으로, 스택이 아닌 정적 영역에 저장된다. 이로 인해 해당 함수가 여러 번 호출되더라도 static 변수의 값은 유지된다.
객체지향 언어 관점에서 static은 클래스 수준에서의 메모리 공유를 의미한다. 일반적으로 객체 인스턴스를 생성하면 그에 속한 멤버 변수는 스택 or 힙에 별도로 할당되지만, static 멤버는 클래스 로딩 시점에 한 번만 할당되어 모든 인스턴스가 동일한 메모리 공간을 공유한다. 따라서 static 멤버는 객체 없이도 클래스 이름만으로 접근할 수 있으며, 이는 해당 데이터가 객체와는 별도로 이미 메모리에 존재하고 있기 때문이다.
C#에서는 static 생성자 또한 존재하며, 이는 C++과 달리 프로그램 실행 시 바로 호출되지 않는다. 대신 해당 클래스에 처음 접근하는 시점에 한 번만 호출된다. static 생성자는 외부에서 직접 호출할 수 없으며, 주로 정적 필드의 초기화나 클래스 수준의 정적 상태 설정에 사용된다. 클래스가 메모리에 로드되고 static 멤버가 실제로 사용되기 직전에 실행되기 때문에, 결과적으로 지연 초기화(lazy initialization)와 유사한 방식으로 동작한다.
5. SOLID 원칙에 대해 설명해주세요.
SOLID 원칙은 객체지향 설계의 다섯 가지 기본 원칙이다.
S (단일 책임 원칙; Single Responsibility Principle)
클래스는 하나의 책임만 가져야 한다.
O (개방-폐쇄 원칙; Open/Closed Principle)
클래스는 확장에 열려있고, 수정에는 닫혀있어야 한다.
L (리스코프 치환 원칙; Liskov Substitution Principle)
부모 클래스의 자리에 자식 클래스를 대체해도 기존의 동작이 변하지 않아야 한다.
I (인터페이스 분리 원칙; Interface Segregation Principle)
클라이언트가 사용하지 않는 인터페이스에 의존하지 않도록 인터페이스를 세분화해야 한다.
D (의존성 역전 원칙; Dependency Inversion Principle)
상위 수준 모듈이 하위 수준 모듈에 의존하면 안 되고, 추상화에 의존해야 한다.
6. 객체지향 프로그래밍의 속성 중 하나인 다형성과 이를 활용한 설계의 장점에 대해 설명해주세요.
객체지향 프로그래밍의 속성 4가지(캡슐화, 상속성, 다형성, 추상화) 중 하나인 다형성은 같은 인터페이스(함수 호출 등)로 서로 다른 동작을 실행할 수 있게 하는 기능이다. C++에서는 주로 가상 함수(virtual function)와 상속을 통해 구현된다.
예를 들어, Character라는 부모 클래스가 있고 Player, Monster가 이를 상속받는다고 할 때, 부모 클래스의 포인터나 참조를 통해 자식 클래스의 오버라이드된 함수를 호출할 수 있다. 이처럼 동일한 함수 호출이 실제 객체의 타입에 따라 다르게 동작하는 것이 다형성이다.
#include <iostream>
using namespace std;
// 부모 클래스
class Character {
public:
virtual void Attack() {
cout << "Character attacks!" << endl;
}
};
// 자식 클래스 1
class Player : public Character {
public:
void Attack() override {
cout << "Player swings a sword!" << endl;
}
};
// 자식 클래스 2
class Monster : public Character {
public:
void Attack() override {
cout << "Monster bites!" << endl;
}
};
int main() {
Character* c1 = new Player();
Character* c2 = new Monster();
c1->Attack(); // Player의 Attack 호출
c2->Attack(); // Monster의 Attack 호출
delete c1;
delete c2;
return 0;
}
7. override와 overload에 대해 설명해주세요.
오버라이딩(Overriding)은 상속 관계에서 부모 클래스의 가상 함수(virtual function)를 자식 클래스에서 재정의하는 것을 의미한다. 반드시 상속 관계에서만 발생하며, 부모 클래스의 함수가 virtual로 선언되어 있어야 한다. 자식 클래스에서는 동일한 이름과 시그니처를 가진 함수를 다시 정의하며, 이때 override 키워드를 붙이면 컴파일러가 시그니처의 정확한 일치를 검사해준다. override는 선택 사항이지만, 코드의 명확성과 오류 방지를 위해 붙이는 것이 권장된다.
오버로딩(Overloading)은 동일한 범위 내에서 함수 이름은 같되, 매개변수의 개수나 타입이 다른 여러 함수를 정의하는 것을 의미한다. 컴파일러는 함수 호출 시 전달된 인자의 형태를 기준으로 어떤 함수가 호출될지 결정하므로, 정적 바인딩(static binding)으로 처리된다. 중요한 점은 반환값의 차이만으로는 오버로딩이 성립하지 않는다. 반드시 인자의 수나 타입이 달라야 한다.
정리하면, 오버라이딩은 런타임 다형성을 구현하기 위한 기능이며, 오버로딩은 컴파일 시점에 동일한 함수 이름을 다양한 형태로 활용하기 위한 문법적 기능이다. 두 개념은 모두 함수 이름을 공유하지만, 적용되는 맥락과 동작 방식은 완전히 다르다.
#include <iostream>
using namespace std;
// override 예제
class Animal {
public:
// 가상 함수
virtual void Speak() {
cout << "Animal makes a sound" << endl;
}
};
class Dog : public Animal {
public:
// override: 부모의 가상 함수를 자식 클래스에서 재정의
void Speak() override {
cout << "Dog barks" << endl;
}
};
// overload 예제
class Logger {
public:
// overload: 함수 이름은 같지만 매개변수 시그니처가 다름
void Log(int value) {
cout << "Log int: " << value << endl;
}
void Log(const string& message) {
cout << "Log message: " << message << endl;
}
void Log(int value, const string& tag) {
cout << "[" << tag << "] " << value << endl;
}
};
int main() {
// override 예시 실행
Animal* a = new Animal();
Animal* d = new Dog();
a->Speak(); // Animal makes a sound
d->Speak(); // Dog barks (런타임에 Dog의 Speak로 바인딩됨)
delete a;
delete d;
// overload 예시 실행
Logger logger;
logger.Log(42); // Log int: 42
logger.Log("System started"); // Log message: System started
logger.Log(500, "ERROR"); // [ERROR] 500
return 0;
}
8. 확장 메서드에 대해 설명하고 어떻게 활용했는지 알려주세요.
확장 메서드(Extension Method)는 C#에서 기존 클래스나 구조체를 수정하지 않고도, 마치 그 타입의 멤버 메서드처럼 외부에서 메서드를 추가할 수 있는 기능이다. 확장 메서드는 정적 클래스 내부에 선언된 정적 메서드이며, 첫 번째 매개변수 앞에 this 키워드를 붙여 확장할 대상을 명시한다. 이로 인해 해당 타입의 인스턴스를 통해 해당 메서드를 인텔리센스를 통해 호출할 수 있게 된다.
몇몇 프로젝트에서 거리 비교할 때 간단하게 사용했다.
public static class Vector3Extensions
{
public static bool IsNear(this Vector3 a, Vector3 b, float threshold)
{
return Vector3.Distance(a, b) <= threshold;
}
}
if (player.position.IsNear(enemy.position, 3f))
{
// 가깝다는 의미로, 관련 내용 처리
}
9. 콜백이란 무엇인가요? 콜백을 사용해본 경험이 있을까요?
콜백(callback)은 특정 작업이 완료되거나 이벤트가 발생했을 때 실행되도록 미리 등록해두는 함수를 의미한다. 일반적으로는 함수를 인자로 전달하거나, 이벤트가 발생했을 때 실행될 함수 포인터 또는 핸들러로 지정하는 방식으로 구현된다.
C++에서는 전통적으로 함수 포인터를 통해 콜백을 구현했지만, 현대 C++에서는 std::function, 람다(lambda), 템플릿 등을 활용해 더 유연하고 타입 안전한 방식으로 콜백을 구성할 수 있다. C#에서는 델리게이트(delegate)와 이벤트(event)를 통해 콜백 기능을 명확하게 제공한다.
서버 프로그래밍 관점에서 콜백은 특히 비동기 I/O 처리에서 핵심적인 역할을 한다. 서버는 다수의 클라이언트 요청을 동시에 처리해야 하므로, 매 요청마다 블로킹 방식으로 처리하면 성능 병목이 발생할 수밖에 없다. 이를 피하기 위해 서버는 커널에 "이벤트가 발생하면 알려줘"라고 등록만 해두고, 실제 처리는 이벤트가 발생했을 때 콜백을 통해 수행한다.
예를 들어, 윈도우의 IOCP 기반 서버에서 `AcceptEx()`나 `WSAEventSelect()` 같은 함수를 호출하면, 이는 연결을 수락하라는 명령이 아니라, 연결이 생겼을 때 알려달라는 등록 행위다. 이때까지 서버는 아무 일도 하지 않고 대기 상태로 유지된다.
이후 클라이언트에서 TCP 방식 연결을 시도한다고 가정하면, 커널은 L3/L4 계층에서 3-way handshake를 처리하고, 연결이 성립되면 백로그 큐에 저장해둔다. 백로그 큐에는 아직 애플리케이션이 처리하지 않은 연결 목록이 저장되어 있다. 연결이 성공했기 때문에 커널은 등록된 IOCP 포트나 이벤트 시스템을 통해 애플리케이션에 "연결이 하나 들어왔다"는 알림을 보낸다.
애플리케이션(L7)은 이 알림을 받으면 사전에 등록해두었던 콜백 함수를 실행한다. 이 콜백은 보통 "AcceptEx()를 호출해 백로그 큐에서 연결을 수락하고, 이후 데이터를 주고받는 처리를 시작하자"는 흐름을 포함한다.
이런 방식을 사용함으로서 서버는 수천 개의 연결을 일일이 블로킹하면서 대기하지 않아도 되며, 이벤트가 실제로 발생했을 때에만 최소한의 처리 단위로 콜백을 실행함으로써 확장성 있는 구조를 유지할 수 있다.
실제로 제작한 IOCP 기반 서버에선 WSARecv, WSASend 함수를 호출해 GQCS로 반환된 오버랩 구조체의 주소를 비교해 함수를 호출하는 방식으로 대규모 클라이언트 연결 처리, 비동기 수신 및 송신 작업 완료 시 작업을 처리한다.
정리하면, 콜백은 단순한 함수 호출이 아니라, 제어 흐름을 위임하고 분리된 타이밍에 작업을 수행할 수 있게 하는 핵심 구조이며, 비동기 서버 구현에서 필수적으로 사용되는 메커니즘이다.
10. 델리게이트(delegate; 대리자)란 무엇인가요?
델리게이트(delegate)는 C#에서 함수를 변수처럼 저장하고 호출할 수 있게 해주는 기능이다.
쉽게 말해, 함수를 가리키는 타입 안전한 함수 포인터라고 보면 된다.
특정 시그니처를 가진 메서드를 대신 호출할 수 있도록 만들어진 객체이며,
함수를 인자로 전달하거나, 나중에 실행할 용도로 주로 사용된다.
델리게이트는 이벤트 처리, 콜백 구현, 전략 패턴 같은 구조에서 자주 쓰이며,
특히 C#의 이벤트(event) 시스템은 델리게이트를 기반으로 작동한다.
정리하면, 델리게이트는 "함수를 값처럼 다룰 수 있게 해주는 도구"이며,
C#에서 콜백이나 동적 함수 호출을 구현할 때 핵심적인 역할을 한다.
'언어 관련 > C#' 카테고리의 다른 글
C# 기술 면접 질문 정리 04 (0) | 2025.04.17 |
---|---|
C++ 템플릿과 C# 제네릭의 차이점 (0) | 2025.02.11 |
C# 파일 입출력에 대하여 (1) | 2025.02.04 |
new 키워드, 그리고 boxing/unboxing에 대하여 (0) | 2025.01.31 |
List<T>와 LinkedList<T> 의 차이점(ps. 트리 구조) (0) | 2025.01.27 |