[C 기술면접] 설계와 메모리 기본 — 절차지향 vs 객체지향·전역변수·3중 포인터
NOTE
질문 3(절차지향 vs 객체지향), 질문 6(전역변수 사용 시 주의점), 질문 7(3중 포인터를 지양하는 이유)을 묶었다. 공통 주제는 “C에는 언어가 지켜 주는 안전장치가 없으므로, 구조(스코프·소유권·인터페이스)로 안전을 만든다” 이다.
Q3. 절차지향과 객체지향의 차이
한 줄 답변
- 절차지향(Procedural): 프로그램을 “무엇을 어떤 순서로 처리하는가(함수/절차)” 중심으로 구성한다. 데이터와 함수가 분리돼 있고, 함수가 데이터를 받아 가공한다.
- 객체지향(Object-Oriented): 프로그램을 “누가(객체) 어떤 책임을 갖는가” 중심으로 구성한다. 데이터와 그 데이터를 다루는 동작을 객체로 묶고, 객체 간 메시지(메서드 호출)로 협력한다.
비교표
| 구분 | 절차지향 | 객체지향 |
|---|---|---|
| 구성 단위 | 함수(절차) | 객체(상태 + 행위) |
| 데이터와 함수 | 분리 — 데이터를 인자로 넘김 | 결합 — 객체가 자기 데이터를 관리 |
| 설계 시선 | 순서·흐름(top-down) | 책임·역할·협력 |
| 데이터 보호 | 약함(구조체 멤버 공개, 전역 변수 접근) | 캡슐화(접근 제어자)로 숨김 |
| 재사용·확장 | 함수 단위 복사/호출, 변경이 호출부 전파 | 상속·합성·다형성으로 확장, 인터페이스 뒤로 변경 은닉 |
| 변경 영향 | 데이터 구조 변경 시 그것을 쓰는 모든 함수가 영향 | 내부 구현 변경이 인터페이스 안에 갇힘 |
| 대표 언어 | C, 초기 BASIC, Fortran | Java, C++, C#, Python |
| 잘 맞는 곳 | 시스템 프로그래밍, 임베디드, 성능이 중요한 코어, 단순 스크립트 | 규모가 크고 변화가 잦은 업무 시스템, 도메인 모델링 |
객체지향의 4가지 특징
- 캡슐화: 상태를 숨기고 정해진 인터페이스로만 접근
- 상속: 공통 속성·행위를 물려받아 재사용
- 다형성: 같은 메시지에 대상에 따라 다르게 동작(오버라이딩/인터페이스)
- 추상화: 핵심 개념만 드러내고 세부를 숨김
어느 쪽이 더 낫나? (균형 잡힌 답)
- 우열이 아니라 문제에 맞는 선택이다. 커널·드라이버·프로토콜 스택처럼 메모리·성능을 직접 통제해야 하는 곳은 절차지향(C)이 자연스럽고, 도메인이 복잡하고 변경이 잦은 곳은 객체지향이 유지보수에 유리하다.
- 절차지향도 모듈화(헤더/소스 분리,
static으로 은닉, 접두어)로 규모 있게 설계할 수 있다. 객체지향이어도 책임 분리가 안 되면 절차지향보다 나쁠 수 있다.
C에서 객체지향 흉내내기 (면접 가산점)
C에는 클래스가 없지만 같은 의도를 구조로 구현한다.
| OO 개념 | C에서의 구현 |
|---|---|
| 클래스/객체 | struct + 그것을 다루는 함수들 (foo_create, foo_destroy, foo_do) |
| 캡슐화 | 불투명 포인터(Opaque Pointer): 헤더에는 typedef struct foo foo_t; 선언만, 구조체 정의는 .c에 숨김. static 함수/변수로 파일 내부 은닉 |
| 생성자/소멸자 | _init/_destroy (또는 _create/_destroy) 쌍 |
| 다형성 | 구조체 안의 함수 포인터(vtable) 또는 디스패치 테이블 |
| 상속/합성 | 구조체의 첫 멤버로 부모 구조체를 포함하면 포인터 캐스팅이 가능(상속 흉내), 또는 멤버로 포함(합성) |
| 인터페이스 | 함수 포인터 집합을 가진 구조체(struct ops) |
/* shape.h : 인터페이스만 공개 */
typedef struct shape shape_t;
struct shape_ops { double (*area)(const shape_t *); void (*destroy)(shape_t *); };
struct shape { const struct shape_ops *ops; }; /* 모든 도형의 공통 첫 멤버 */
/* circle.c : 구현은 숨김 */
typedef struct { shape_t base; double r; } circle_t;
static double circle_area(const shape_t *s) { const circle_t *c = (const circle_t *)s; return 3.14159 * c->r * c->r; }
static void circle_destroy(shape_t *s) { free(s); }
static const struct shape_ops circle_ops = { circle_area, circle_destroy };
shape_t *circle_new(double r) { circle_t *c = malloc(sizeof *c); c->base.ops = &circle_ops; c->r = r; return &c->base; }
/* 사용: 다형적 호출 */
double a = s->ops->area(s); /* s가 원이든 사각형이든 같은 코드 */→ 참고: 불투명 포인터, 함수 포인터와 디스패치 테이블, 공개 래퍼와 내부 코어 분리
꼬리 질문
| 질문 | 답변 요지 |
|---|---|
| 다형성이 왜 중요한가? | 호출부가 구체 타입을 모르고 인터페이스에만 의존하게 해, 새 타입 추가 시 기존 코드를 수정하지 않게(OCP) 한다. switch(type) 분기를 없앤다. |
| 상속보다 합성을 권하는 이유? | 상속은 부모-자식 결합도가 높다(부모 변경이 자식을 깨뜨림). 합성은 필요한 기능만 조립해 유연하다. |
| SOLID란? | SRP(단일 책임), OCP(확장에 열림·수정에 닫힘), LSP(자식이 부모를 대체 가능), ISP(인터페이스 분리), DIP(구체가 아닌 추상에 의존). |
| 절차지향의 단점은? | 데이터 구조가 바뀌면 흩어진 함수들을 모두 고쳐야 하고, 전역 상태가 늘면 추적이 어렵다. |
| C++/Java의 객체 오버헤드는? | 가상 함수 호출(vtable 간접 호출), 객체 헤더, 힙 할당 등. 대부분 무시할 수준이지만 초저지연/임베디드에서는 의식한다. |
Q6. 전역변수를 활용할 때 주의할 점
한 줄 답변
전역변수는 어디서든 읽고 쓸 수 있어서 변경 주체 추적이 어렵고, 멀티스레드에서는 데이터 레이스가 되며, 모듈 간 숨은 의존성이 생긴다. 쓰더라도 최소화하고 static으로 가두고, 접근을 함수로 감싸고, 동기화를 건다.
주의점 정리
| 항목 | 문제 | 대응 |
|---|---|---|
| 스코프 / 링키지 | 전역은 기본 external linkage라 다른 파일과 이름이 충돌하거나 의도치 않게 공유됨 | 파일 내부용은 static, 접두어(mod_ / g_), 심볼 숨김 |
| 선언 vs 정의 | 헤더에 변수를 정의하면 include마다 중복 정의 → 링크 에러 | 헤더에는 extern int g_x;(선언), 정확히 하나의 .c에서 int g_x = 0;(정의). GCC 10부터 -fno-common이 기본이라 중복 정의가 에러로 드러남 |
| 멀티스레드 경쟁 | 락 없이 읽고 쓰면 데이터 레이스 = UB, count++도 비원자적 | mutex/rwlock/atomic으로 보호. 또는 스레드별 복사(_Thread_local, __thread, pthread_key_t) |
| 가시성/최적화 | 컴파일러가 레지스터에 캐싱해 다른 스레드/핸들러의 변경이 안 보일 수 있음 | atomic 사용. 시그널 핸들러가 쓰는 변수는 volatile sig_atomic_t (volatile은 스레드 동기화 수단이 아님) |
| 재진입 불가(Reentrancy) | 전역/정적 버퍼를 쓰는 함수(strtok, localtime)는 스레드/시그널에서 위험 | 재진입 버전(strtok_r, localtime_r) 또는 호출자가 버퍼를 제공 |
| 숨은 의존성 / 결합도 | 함수 시그니처에 안 보이는데 전역 값에 따라 동작이 바뀜 → 테스트·재사용 어려움 | 필요한 상태는 구조체(컨텍스트)로 묶어 인자로 전달, 전역은 설정/싱글턴 최소한 |
| 초기화 | 정적 저장 기간 변수는 0으로 초기화되지만, 초기값 식은 상수 표현식이어야 함(C). 다른 전역 값으로 초기화 불가 | 초기화 함수(xxx_init)에서 명시적으로 설정, pthread_once |
| 초기화 순서/수명 | 프로세스 전체 수명. 종료 시 정리 순서가 불분명 | 명시적 init/destroy, 전역 객체 해제 순서 관리 |
| 프로세스 경계 | fork 후 자식은 복사본을 가짐(공유 아님). 다른 프로세스와는 전역을 공유 못 함 | 공유가 필요하면 공유메모리 등 IPC |
| 메모리 | 큰 전역 배열은 .data/.bss를 키워 로드/주소 공간 사용. 읽기 전용은 .rodata | const 적극 사용, 필요 시 동적 할당 |
| false sharing | 서로 다른 스레드가 쓰는 전역 변수가 같은 캐시 라인에 있으면 성능 저하 | 정렬/패딩(alignas(64)), 변수 분리 |
| 디버깅/추적 | 누가 값을 바꿨는지 알기 어렵다 | 접근 함수(getter/setter)에 로그·assert, watchpoint(gdb watch) |
권장 패턴
/* mod.h */
int mod_get_limit(void);
void mod_set_limit(int v);
/* mod.c */
static int s_limit = 100; /* 파일 내부로 가둠 */
static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER;
int mod_get_limit(void) { pthread_mutex_lock(&s_lock); int v = s_limit; pthread_mutex_unlock(&s_lock); return v; }
void mod_set_limit(int v) { pthread_mutex_lock(&s_lock); s_limit = v; pthread_mutex_unlock(&s_lock); }- 전역을 없앨 수 있으면 없앤다: 컨텍스트 구조체를 만들어 인자로 전달하면 테스트와 멀티 인스턴스가 쉬워진다.
- 읽기 전용 설정값이면
const로, 초기화 후 변경이 없다면 락 없이도 안전하게(초기화는pthread_once또는 시작 시 1회).
→ 참고: extern과 static — 링키지와 다중 파일 공유, 실무 C 코드 관례와 UB 함정 정리 (A7, A9, B8)
꼬리 질문
| 질문 | 답변 요지 |
|---|---|
static 전역과 일반 전역 차이? | static은 internal linkage — 그 파일 안에서만 보임. 일반 전역은 다른 파일에서 extern으로 접근 가능. |
| 전역 변수가 저장되는 곳? | 초기값 있음 → .data, 초기값 없음/0 → .bss(실행 시 0으로 채움), const → .rodata. |
지역 static 변수와 전역의 차이? | 수명은 프로그램 전체로 같지만 스코프가 함수 안으로 제한됨. 스레드 안전성 문제는 동일(공유됨). |
volatile을 전역에 붙이면 스레드 안전해지나? | 아니다. 최적화(캐싱) 방지만 하고 원자성/순서 보장은 없다. atomic이나 락을 쓴다. |
| 싱글턴도 전역 아닌가? | 본질적으로 전역 상태다. 접근 지점을 통제할 뿐이고, 의존성 주입이 어렵다는 단점은 같다. |
Q7. 3중 포인터(***)를 지양하는 이유
한 줄 답변
간접 참조 단계가 늘수록 가독성·소유권 추적·오류 처리·안전성이 급격히 나빠지는데, 그 복잡도를 감수할 만한 이점이 거의 없고 구조체 등 더 명확한 대안이 있기 때문이다.
이유
| 이유 | 설명 |
|---|---|
| 가독성/이해 비용 | ***p가 “무엇의 무엇의 무엇”인지 읽는 사람이 매번 해석해야 한다. (*p)[i], **p 같은 우선순위 혼동으로 실수 |
| NULL 검사 폭증 | 각 단계(p, *p, **p)마다 NULL일 수 있어 방어 코드가 3중으로 늘고, 하나라도 빠지면 크래시(SIGSEGV) |
| 소유권/수명 불명확 | 누가 어느 단계를 malloc하고 free하는지 불분명 → 누수, 이중 해제, 해제 순서 오류(안쪽→바깥쪽 순서로 해제해야 함) |
| 오류 처리 복잡 | 중간 단계 할당 실패 시 이미 할당된 것들을 전부 롤백해야 함 (goto 정리 코드 비대) |
| const 정확성 문제 | char ** → const char ** 변환은 C에서 안전하지 않아 허용되지 않는다. 단계가 늘수록 const 선언이 지저분해짐 |
| 성능 | 각 *는 메모리 로드 1회 — 포인터 체이싱(pointer chasing)으로 캐시 미스 증가, 컴파일러 최적화(별칭 분석) 어려움 |
| API 오용 가능성 | 호출자가 &&p로 무엇을 넘겨야 하는지 헷갈림. 타입 시스템이 실수를 못 막는다 |
| 디버깅/테스트 난이도 | gdb에서 p ***p로 펼쳐야 하고, 단위 테스트 입력 구성이 번거로움 |
| 대안이 있음 | 구조체로 묶거나 반환값을 쓰면 간단하다 |
이중 포인터(**)는 괜찮은가?
필요한 경우에 한해 정당하다.
- 호출 결과로 포인터 자체를 바꿔야 할 때(출력 매개변수):
int alloc_buf(char **out), 연결 리스트 head 변경 - 문자열 배열:
char **argv - 이미 널리 알려진 관용구라 리뷰어가 이해하기 쉽다.
3중부터는 대개 “구조가 잘못 설계됐다”는 신호다.
대안
/* 나쁜 예: 문자열 배열을 출력 매개변수로 돌려주기 (***) */
int get_names(char ***out_names, int *out_count);
/* 좋은 예 1) 구조체로 묶기 */
typedef struct {
char **items; /* 문자열 배열 */
int count;
} str_list_t;
int get_names(str_list_t *out); /* 포인터 한 단계 */
void str_list_free(str_list_t *list); /* 해제 책임을 한 곳에 */
/* 좋은 예 2) 반환값으로 */
str_list_t *get_names(void); /* 생성/소멸 쌍으로 소유권 명확 */| 대안 | 효과 |
|---|---|
| 구조체로 묶기 | 개수·포인터를 함께 관리, 단계 축소, 해제 함수를 한 곳에 |
| create / destroy 쌍 | 소유권이 명확(만든 쪽의 API로 해제) |
| 불투명 핸들(opaque pointer) | 내부 구조를 숨기고 단일 포인터만 노출 |
| 연속 메모리 할당 | 2차원 배열을 T **로 쪼개 할당하지 말고 T * 한 덩어리 + 인덱스 계산(a[i*cols + j]) |
| 반환값/에러 코드 분리 | 출력 매개변수를 줄이고 int 에러 코드를 반환 |
typedef로 이름 붙이기 | 의미를 드러내 읽기 쉽게(다만 복잡도 자체는 안 줄어듦) |
→ 참고: 개수 필드 + 포인터의 포인터 — 동적 문자열 배열 만들고 해제하기, 동적 할당 소유권 — caller free vs callee create·destroy 쌍, pthread_create에 구조체 포인터 넘기기 — void 포인터와 이중포인터
꼬리 질문
| 질문 | 답변 요지 |
|---|---|
| 이중 포인터가 왜 필요한가? | C는 값 전달(pass by value)이라 함수가 호출자의 포인터 변수 자체를 바꾸려면 그 포인터의 주소를 받아야 한다. |
char **argv와 char *argv[]의 차이? | 함수 매개변수에서는 동일하다(배열이 포인터로 decay). |
2차원 배열 int a[3][4]와 int **는 같은가? | 아니다. a는 연속된 12개 int이고 int (*)[4]로 decay한다. int **는 포인터 배열이라 메모리 모양이 다르다 — 서로 대입/캐스팅하면 안 된다. |
| 동적 2차원 배열의 좋은 방법? | 한 번에 malloc(rows*cols*sizeof(T))로 연속 할당하고 a[i*cols+j]로 접근. 해제도 free 한 번. |
| 포인터 체이싱이 느린 이유? | 각 단계가 메모리 접근이며 서로 의존적이라 CPU가 선행 로드(prefetch)·병렬화하기 어렵고 캐시 미스가 누적된다. |