-
제어의 역전(Inversion of Control) 이해하기 1부: 객체 지향 이전의 C언어 구현Programming/TIL 2024. 11. 7. 22:56
C언어의 함수 포인터를 활용하여 제어의 역전(IoC, Inversion of Control)의 기본 개념과 구현 방법을 설명합니다.
Spring이나 객체 지향 프로그래밍의 고급 개념 없이, C언어의 qsort() 함수 예제를 통해 콜백 메커니즘의 동작 원리와 장점을 살펴봅니다. 함수 포인터를 활용한 코드 결합도 낮추기와 재사용성 향상 방법을 다룹니다.개요
최근 너무 흥미로운 블로그 글을 발견했습니다.
제어의 역전을 C언어 예시로 설명하고, 이를 객체 지향적 관점으로 확장하며 설명하는 글이었는데요.
읽으면서 이해한 내용을 정리하고, 글 소개도 할 겸 글을 써보기로 했습니다.본 글은 해당 블로그 글을 기반으로 제어의 역전을 설명합니다.
설명과 코드 예시는 블로그 글을 참고하되, 최대한 이해하기 쉽도록 제 나름의 설명을 많이 덧붙였습니다.
덕분에 글이 상당히 길어져 3부작으로 나누어 작성하기로 했습니다.이 글은 3부작의 첫 번째 글로, Spring 프레임워크나 객체 지향 디자인 패턴 같은 고수준 개념들은 모두 배제하고 C언어와 함수 포인터만으로 제어의 역전을 설명하고자 합니다.
편의점 점장과 직원의 관계를 통해 제어의 역전을 비유적으로 설명하고, 실제 C언어로 어떻게 구현되는지를 살펴보겠습니다.단순 번역이 아닌 제 나름의 해설본에 가까운 글입니다.
내용이 많이 달라질 예정이라 이 글과는 별개로 원문을 읽어보시는 것도 강력히 추천드립니다.용어 정리
Inversion of Control, IoC는 제어의 역전 혹은 제어의 반전으로 번역됩니다.
글에서는 "제어의 역전", "제어 역전"을 주로 사용하겠습니다.글은 코드를 사용하는 쪽을 메인 모듈, 코드를 제공하는 쪽을 서브 모듈이라고 표현하겠습니다.
모듈은 작은 단위의 코드 조각으로, 다른 모듈에서 사용할 수 있는 부분과 그렇지 않은 부분이 있는 코드입니다.
예를 들어 메인 모듈에서#include "my_module.h"
를 통해 서브 모듈을 포함하면, 서브 모듈의 공개된 함수나 변수를 사용할 수 있습니다. 하지만 구현을 위한 내부 함수나 변수는 사용할 수 없죠.흔히 말하는 패키지, 라이브러리는 모듈의 집합이라고 볼 수 있을 텐데요. 글에서는 특별한 사유가 없는 한 모두 "모듈"이라는 용어로 통일하겠습니다.
서브 모듈은 다른 개발자가 작성한 코드일 수도, 내가 작성한 코드일 수도 있습니다.
메인 모듈과 서브 모듈을 같은 사람이 작성했더라도 모듈의 관점에서 작성자, 사용자를 구분하여 설명하겠습니다.
2주 전, 6개월 전 내가 작성한 코드는 또 남이 작성한 코드처럼 느껴지기도 하니까요.제어의 역전이란?
제어의 역전이란 콜백 메커니즘을 통해 프로그램 제어를 역전하여 코드의 결합도를 낮추는 방식입니다.
메인 모듈과 서브 모듈의 관계를 편의점 점장과 직원에 빗대어 천천히 설명해 보겠습니다.
콜백이란?
콜백이란 단순히 말해 함수 포인터입니다.
퇴근하는 편의점 점장이 직원에게 전화번호를 남기며 이렇게 말합니다.
"일 하다 무슨 일 생기면 전화해(call back)"마찬가지로 메인 모듈이 서브 모듈에게 "일하다 무슨 일 생기면 이 함수를 호출해"라고 전달하는 것이 콜백입니다.
전화를 걸기 위해서는 전화번호를 알아야 하듯, 함수를 호출하기 위해서는 함수의 주소가 필요합니다.
함수 포인터를 전달해 줘야 하는 거죠.
객체 지향 언어에서는 함수 포인터를 전달하는 게 명확히 보이지 않을 수 있습니다. 주로 객체를 전달하니까요.
그래도 내부 동작을 살펴보면 결국 객체를 통해 함수 포인터를 전달하고 있음을 확인할 수 있습니다.
마치 점장이 직원에게 명함을 전해주는 것처럼, 명함 안에 점장의 번호가 적혀있는 거죠.콜백이라는 용어는 객체 지향 언어에서 주로 사용되지만, 함수 주소를 전달할 수만 있다면 어떤 언어에서든 적용 가능한 개념입니다.
객체 지향 관점에서의 콜백은 다음 편에서 다루도록 하겠습니다.
프로그램 제어가 역전된다는 의미는?
프로그램 제어(control)란 "누가 다음에 실행될 코드를 결정하는가"라고 정의할 수 있겠습니다.
"제어의 역전"이 있다면 당연히 제어가 역전되지 않은 상황, 즉 평범한 제어 흐름도 있을 것입니다.
제어가 역전되지 않은 편의점의 모습을 한번 보겠습니다.
점장이 업무 순서를 일일이 정하고 직원에게 지시하는 모습입니다.
보기만 해도 피곤합니다. 당하는 직원도 피곤하겠지만, 이를 지시하는 점장도 피곤하긴 마찬가지일 겁니다.
CCTV로 계속 상황을 모니터링하면서 업무 지시를 내려야 하니까요.사실 대부분의 편의점 점장님들은 이런 상황을 피하고 싶어합니다. 모든 사장님의 꿈은 자동 사냥이니까요.
알아서 잘하는 경력직 직원을 뽑아 업무를 위임하고, 최대한 신경을 끄고 싶어 합니다. 제어를 넘겨주고 싶죠.알잘딱 직원이 있으면 점포 상태를 일일이 모니터링할 필요 없고, 빽빽한 업무 지시를 내릴 필요가 없으니, 점장과 편의점의 결합도가 낮아집니다. 그 시간에 다른 중요한 일을 할 수도 있겠죠.
한편, 경력직 직원이라고 모든 일을 전부 알아서 다 처리할 수는 없습니다.
점포 특성에 따라 업무 처리 방식이 다를 수도 있고, 직원이 해결하지 못하는 돌발 상황이 발생할 수 있죠.예를 들어 어떤 편의점은 겨울철에 호빵을 팔기도 합니다. 모든 편의점이 호빵을 팔지는 않기 때문에 경력직 직원이라도 호빵 조리법은 알지 못할 수 있죠.
이럴 땐 점장이 호빵 조리법 메뉴얼을 작성해 직원에게 전달해야겠죠.다른 편의점 업무는 직원이 알아서 하되, 호빵 주문이 들어오면 "점장이 작성한 메뉴얼대로" 호빵을 조리하고 판매합니다.
점장이 직원에게 제어를 넘겨주되, (호빵이) 필요한 상황에는 점장의 지시를 따르게 하는 것입니다.마찬가지로 메인 모듈이 서브 모듈의 함수를 일일이 호출하며 프로그램을 작성하는 게 평범한 제어 흐름이라면,
서브 모듈에게 제어를 넘겨주되 호빵 메뉴얼, 콜백 함수를 통해 필요한 상황에서 제어를 돌려받을 수 있도록 하는 것이 제어의 역전입니다.제어의 역전을 통해 점장과 편의점의 결합도가 낮아지듯, 메인 모듈과 서브 모듈의 결합도도 낮아질 수 있습니다.
모든 편의점에서 통용되는 업무방식, 공통 로직을 서브 모듈에 구현해 두고, 메인 모듈은 호빵 매뉴얼처럼 따로 필요한 부분만 함수로 구현하여 서브 모듈에 전달할 수 있으니까요.C언어로 보는 제어의 역전
문자열 리스트(배열)을 받아 정렬하는 함수를 작성한다고 가정해 보겠습니다.
함수는 다양한 정렬 순서를 지원해야 합니다.
현재 지원하는 정렬 옵션은 오름차순/내림차순 대소문자 무시/구분 4가지 옵션이 있습니다.enum sort_type { ALPHA_ASC, ALPHA_DESC, CASE_INSENSITIVE_ASC, CASE_INSENSITIVE_DESC };
정렬하는
sort
함수를 먼저 일반적인 방법으로 구현하겠습니다.
실제 정렬 로직은 설명을 위해 생략하고, 옵션에 따라 정렬 순서를 결정하는 부분만 보겠습니다.void sort(const char *strings[], size_t n_strings, enum sort_type how) { /* ... 생략됨 ... */ bool is_before; switch (how) { case ALPHA_ASC: is_before = compare_str(strings[i], strings[j]); break; case ALPHA_DESC: is_before = compare_str(strings[j], strings[i]); break; case CASE_INSENSITIVE_ASC: is_before = compare_str_ignore_case(strings[i], strings[j]); break; case CASE_INSENSITIVE_DESC: is_before = compare_str_ignore_case(strings[j], strings[i]); break; } /* ... 생략됨 ... */ }
위 코드는 아래와 같은 계층 구조를 가집니다.
메인 모듈에서 모든 제어가 수행되는 구조입니다.이 구현에는 몇 가지 문제가 있습니다.
- 새로운 정렬 방식을 추가하려면
sort
함수를 직접 수정해야 합니다. - 방식이 늘어날수록
sort
함수는 점점 더 길고 복잡해질 것입니다. - 정렬 로직을 다른 프로그램에서 재사용하기 어렵습니다.
프로그래밍에서 정렬을 매우 일반적인 작업입니다. 여러모로 써야 할 일이 많죠.
정렬 로직을 따로 모듈화하면 코드 중복을 줄이고 재사용성을 높일 수 있을 것 같습니다.마침, 경력직 직원이 한 명 있습니다. 정렬 로직을 잘 알고 있고, 나름 최적화까지 할 줄 아는 직원이죠.
바로 C언어 표준 라이브러리의qsort()
라는 퀵 정렬 함수입니다.라이브러리 제작자는 피벗을 잡고 데이터를 분할 정복하는 퀵 정렬 핵심 알고리즘은 구현해 두었지만, 사용자가 어떤 기준으로 데이터를 정렬할지는 알 수 없습니다.
단순 오름차순일지, 내림차순일지, 대소문자를 구분할지, 구조체의 특정 멤버를 기준으로 정렬할지 등 사용 예시가 너무 다양하니까요.그래서
qsort
함수는 정렬에 사용할 비교 함수를 사용자가 직접 제공하게 되어 있습니다.
콜백 함수, 포인터를 통해 전달받은 비교 함수를 호출하며 정렬을 수행합니다.다시 말해 프로그램의 제어를 프로그램 제어를
qsort
함수에 넘겨주고, 비교가 필요할 때마다 제어를 다시 돌려받는 방식입니다.qsort
함수의 시그니쳐를 보면 더욱 명확합니다.void qsort(void* ptr, // 정렬할 데이터의 시작 주소 size_t count, // 데이터의 개수 size_t size, // 데이터의 크기 int (*comp)(const void*, const void*) // 콜백, 비교 함수의 포인터 );
qsort
를 사용해 이전 코드를 다시 작성해 보겠습니다.main
모듈에서sort
함수를 만들 필요 없이,qsort
함수를 사용하면 됩니다.int main(void) { /* ... 생략됨 ... */ const char *strings[] = {"turkey", "56709", "Turkey", "polish", "Polish", "404"}; int (*pcomp)(void *, void *); switch (how) { case ALPHA_ASC: pcomp = compare_str; break; case ALPHA_DESC: pcomp = compare_str_reverse; break; case CASE_INSENSITIVE_ASC: pcomp = compare_str_ignore_case; break; case CASE_INSENSITIVE_DESC: pcomp = compare_str_ignore_case_reverse; break; } qsort(strings, n_strings, sizeof(*strings), pcomp); /* ... 생략됨 ... */ } // 콜백 함수 int compare_str(const void *a, const void *b) { return strcmp(*(const char **)a, *(const char **)b); } int compare_str_ignore_case(const void *a, const void *b) { return _stricmp(*(const char **)a, *(const char **)b); } /* ... 생략됨 ... */
이제 코드는 아래와 같은 계층 구조를 가집니다.
이제 새로운 비교 방식을 추가하기 위해
sort
함수 또는qsort
함수를 수정할 필요가 없습니다.
필요한 비교 함수를 그때그때 정의해서qsort
함수에 전달하기만 하면 됩니다.물론 지금 설계도 완벽하지 않습니다. 코드의 결합도를 더 낮출 방법이 있을 텐데요.
이는 이후에 의존성 주입(Dependency Injection)을 설명하는 글에서 자세히 다루도록 하겠습니다.결론
제어의 역전을 사용하면 코드 결합도를 낮추고 재사용성과 유지보수성을 높일 수 있습니다.
중요한 알고리즘을 한 번만 구현하고 필요한 부분을 콜백으로 제공하면 되니, 새로운 기능을 추가하거나 버그를 수정할 때 기존 코드를 건드릴 필요가 없죠.제어의 역전은 C언어의 함수 포인터를 사용해서 간단히 구현할 수 있는 강력한 설계 원칙입니다.
"제어의 역전"이라는 이름으로 불리지 않았을 뿐, 함수의 주소를 이용한 프로그래밍은 초기 어셈블리 언어부터 시작해 고급 언어로 퍼져 나간 개념이지요.다음 글에서는 이 단순한 개념이 객체 지향 프로그래밍에서 어떻게 더 풍부하게 발전하는지 살펴보겠습니다.
프레임워크의 탄생과 인터페이스를 통한 콜백 구현 등 다양한 예시를 통해 제어의 역전을 더 깊이 있게 이해 해보겠습니다.읽어주셔서 감사합니다.
- 새로운 정렬 방식을 추가하려면