01. C언어 소개
C언어는 1970년에 벨 연구소의 톰프슨이 B라는 언어를 만들었고 그후 같은 연구소의 데니스 리치에 의해 C언어가 개발되었습니다. 그는 사용하기 편하면서도 운영체제를 만들 수 있을 정도의 강력한 언어를 만들 수 있을 정도의 강력한 언어를 만들기 원했는데 그의 시도는 성공했고 그 결과물로 C가 탄생하게 되었습니다.
C언어는 B언어가 발전한 모습이라고 했는데, B언어 이전에는 CPL이라는 언어가 있었고 이는 ALGOL 언어에서 업그레이드 된 언어입니다. C 언어는 B 언어의 다음이라는 뜻으로 이름이 지어졌는데 언어 개발의 최대 목적은 유닉스 OS를 효율적으로 작성할 수 있는 언어를 만들어보자는 것이었습니다. C는 개발된 이후 유닉스를 비롯한 거의 모든 시스템환경에 포팅이 되어 사용되어 왔고 그 이전의 언어들을 대체하는 범용적인 언어가 되었습니다.
유닉스의 경우 대부분의 OS코드가 C로 만들어졌고 그 위에서 돌아가는 애플리케이션도 상당수가 C를 이요하여 만들었고 또한 만들어지고 있습니다.
C언어는 서로 다른 환경에서 개별적으로 발전을 거듭하다 ANSI(American National Standards Institute)라는 표준단체에서 표준안을 발표하면서 더욱 체계적으로 발전하기 시작했습니다. C 언어는 지금도 계속해서 발전하고 있는데, 지금까지 개발된 기능만으로도 가장 뛰어난 언어중 하나라고 평가할 수 있습니다.
C 언어의 특징
C언어는 다양한 기능들을 제공하고 있습니다. C 언어가 유닉스 운영체제 개발에 사용되었을 정도로 시스템 프로그래밍을 하는데 필요한 모든 기능을 제공하고 있습니다. 뿐만 아니라 데이터를 처리하는데 필요한 파일 관련 기능과 수학에서 사용할 수 있는 수식 처리 기능 그리고 화면을 제어하고 처리하는데 필요한 그래픽 기능 등 다른 프로그래밍 언어에서 제공하는 여러 기능들을 대부분 제공하고 있습니다.
C언어는 하이레벨의 기능뿐만 아니라 시스템을 직접 다루는 로우레벨의 기능도 제공하고 있습니다. C언어 이전에는 대부분 어셈블리어로 처리하던 모듈들의 상당부분이 C언어로 대체가 가능할 정도의 기능을 제공하는 것입니다. C 언어의 탄생 이후, 어셈블리어의 중요성이 많이 떨어지게 되었고 기계제어의 많은 부분에서 C언어가 사용되고 있습니다.
C의 이러한 특징 때문에 C를 중급언어라고 표현합니다. C는 구조화되고 표준화된 고급언어의 기능과 시스템의 리소스를 직접 사용하는 저급언어의 특성을 모두 갖췄기 때문에 중급언어로 분류가 됩니다.
이밖에도 C는 구조적 프로그램을 작성할 수 있도록 만들어진 언어이며 비트 조작이 가능한 섬세한 언어입니다. 그리고 표준 언어로써 다른 하드웨어 플랫폼에 별다른 코드의 변환없이 사용할 수 있을 정도로 이식성이 뛰어난 언어입니다. 물론 하드웨어 플랫폼을 바꾸게 되면 소스코드에 대한 컴파일은 새로 해주어야 합니다.
C 언어는 필자가 특별히 강조하지 않아도 C 언어 이전의 언어들보다 강력한 언어임은 독자들이 이미 알고 있을 것입니다.
02. C프로그래밍
이 절에서 C언어의 전체 문법을 설명하지는 않습니다. 도움이 될만한 부분만 간추려서 정리하고 소개합니다. C언어와 관련해 도움이 될만한 부분은 '함수', '포인터' 그리고 '스트럭처'를 뽑았습니다. 다른 중요한 내용도 많지만 이 부분만큼은 함께 정리하고 넘어가면 좋습니다. 그럼 이들을 간단히 살펴보겠습니다.
함수
C는 함수들의 연동으로 프로그램이 시작되고 끝이 납니다. 프로그램은 main() 함수에서 시작하는 첫번째 작업으로부터 출발해서 여러 함수들의 호출과 실행으로 다양한 작업들을 수행하게 됩니다.
함수는 보통 다음과 같은 형태로 작성이 됩니다.
여기서 '리턴형'은 함수가 명령을 실행한후 반환하는 데이터의 타입을 의미합니다. 리턴형에는 기본적인 데이터 타입인 int, char, float, long 등이 올 수도 있고 사용자가 직접 만든 스트럭처의 타입도 리턴형으로 사용할 수 있습니다. 만일 반환하는 데이터가 전혀 없다면 리턴형은 void로 대체해야 합니다.
'함수 이름'은 함수의 식별자가 되는데 리턴형 바로 뒤에 적어야 합니다.
'인자'는 함수 이름 뒤에 괄호 속에 위치를 합니다. 인자를 통해 외부의 데이터를 함수에게 넘겨줄 수 있습니다. 인자는 하나 이상 여러개가 올 수도 있고 하나도 없을 수도 있습니다. 인자가 하나도 없을 경우에는 괄호 속에 void를 명시하면 됩니다. 만일 인자가 하나이상 들어올 때는 콤마(,)를 이용하여 구분하면 됩니다.
'함수 내용'은 함수가 수행하는 모든 일을 의미하는 것으로 함수의 실제 내용이 됩니다. 함수의 리턴형이나 이름 그리고 인자는 함수 내용을 호출하고 그 결과를 받아가기 위한 선언자일뿐입니다.
포인터
변수를 정의하게 되면 변수에게는 left value(왼쪽 값-lvalue)와 right value(오른쪽 값-rvalue)가 생성됩니다. lvalue는 변수가 위치하는 메모리의 번지값이 되고 rvalue는 그 메모리 번지 속에 저장되어 있는 실제값이 됩니다. 예를 들어 다음과 같이 변수를 선언했다고 하겠습니다.
int intVal = 10;
그러면 intVal라는 변수에게는 lvalue와 rvalue가 할당이 되었을텐데 이를 눈으로 확인하기 위해 다음과 같이 printf문을 실행시켜보겠습니다.
printf("lvalue: %p, rvalue: %d", &intVal, intVal);
완전한 C코드와 실행방법은 아래와 같습니다.
#include <stdio.h> int main(void) { int intVal = 10; printf("lvalue: %p, rvalue: %d\n", &intVal, intVal); }
그리고 코드가 완성되었으면 파일로 저장한 후, cc 컴파일러나 gcc 컴파일러로 다음과 같이 컴파일을 합니다. 파일명이 pointer.c라고 하면 다음과 같이 합니다.
[root@dev-web c_programming]# ./pointer.out
lvalue: 0x7ffdf66a63fc, rvalue: 10
printf 문의 실행을 통해 inVal 변수에게 메모리 번지값을 나타내는 lvalue와 실제 값을 나타내는 rvalue가 할당되었음을 확인했을 것입니다. 개발자나 사용자에게는 실제 값이 들어있는 rvalue가 중요하겠지만 컴파일러에게는 변수의 위치를 찾게 해주는 lvalue가 더 중요할 것입니다. lvalue를 찾아야 rvalue를 사용하게 만들어주던지 말던지 할 수 있기 때문입니다. 그리고 rvalue는 개발자나 사용자가 임의로 할당해주기 때문에 언제든지 변할 수 있는 값이지만 lvalue는 그렇지 않습니다.
포인터 또한 lvalue와 rvalue를 갖게 되는데 포인터의 lvalue는 다른 변수처럼 포인터가 위치한 메모리 번지를 가지게 됩니다. 하지만 포인터의 rvalue는 다른 변수나 데이터의 lvalue를 가지게 됩니다. 즉, 포인터는 rvalue에 데이터 값을 가지는 것이 아니라 참조하고자 하는 변수의 lvalue를 가짐으로써 그 변수가 가지고 있는 rvalue를 이용할 수도 있고, 포인터의 rvalue에 또다른 변수의 lvalue를 할당함으로써 전혀 다른 데이터를 참조하도록 만들 수 있습니다.
포인터는 '*'기호를 이용하여 정의를 하게 되는데 예를 들어 int형의 포인터를 만들려면 다음과 같이 합니다.
int *intPtr;
위와 같이 intPtr이라는 이름의 int형 포인터를 선언하고 나면 intPtr은 다른 int형의 변수들의 주소를 할당받아 원하는 조작을 수행할 수 있습니다.
#include <stdio.h> int main(void) { int *intPtr; int intVal = 10; intPtr = &intVal; printf("intPtr lvaue : %p, rvalue: %x\n", &intPtr, intPtr); printf("intVal lvaue : %p, rvalue: %d\n", &intVal, intVal); *intPtr = 30; printf("intVal lvaue : %p, rvalue: %d\n", &intVal, intVal); }
위의 예제를 실행시켜보면 intVal의 lvalue값과 intPtr의 rvalue값이 동일함을 알 수 있습니다. 그리고 '*intPtr = 30;'라는 문장을 통해 intVal의 rvalue까지 10에서 30으로 변했음을 확인할 수 있습니다. 이번에는 배열에 포인털르 활용하는 예를 보도록 하겠습니다.
#include <stdio.h> #define MAXNUM 10 void setString(char* string) { int loop; char *cPtr, cVal; cPtr = string; for(loop = 0; loop < MAXNUM; loop++) { *cPtr = '1'; cPtr++; } *cPtr = '\0'; } int main(void) { char string[MAXNUM]; // string이라는 이름의 배열 생성 string[MAXNUM] = '\0'; // '\0'은 문자열의 끝에 null 문자를 추가하여 배열이 하나의 문자열로 사용되는데 문제 없도록 printf("string : %s\n", string); setString(string); // setString 함수를 호출하면서 인수로 string 배열을 넘겨줌. printf("string : %s\n", string); }
5번 라인의 setString 함수를 보면 string 배열을 string이라는 이름의 포인터로 받았음을 알 수 있습니다. 그런 다음 11~15라인의 for 구문을 이용하여 배열속의 값을 모두 '1'로 변환하고 16라인에서 마지막 문자를 '\0'로 지정한 후 함수는 종료됩니다. 마지막으로 25라인에서 그 결과를 찍어보면 string 배열 속의 값은 모두 '1'로 변환되었음을 알 수 있습니다.
[root@dev-web c_programming]# ./arrayPtr.out
string : `▒e
string : 1111111111
이번에는 함수이름을 포인터에 할당하여 이를 호출하고 활용하는 예제를 보도록 하겠습니다.
#include <stdio.h> // 함수의 이름을 대체할 매크로 선언 #define FUNC1 func1 #define FUNC2 func2 // func1(), func2() 함수 작성 int func1(int intVal1){ return --intVal1; } int func2(int intVal2){ return --intVal2; } // 인수로 들어오는 함수 이름을 실행시키는 함수 int runFunc(char* string, int inVal) { int retVal; // 함수를 이용할 포인터 선언 int (*funcPtr)(int retVal); // 포인터에 함수이름을 할당 funcPtr = string; // 함수를 실행하고 그 결과 값을 리턴 return (*funcPtr)(inVal); } int main(void) { int intVal = 3; intVal = runFunc(FUNC2, intVal); printf("After running func2 intVal: %d\n", intVal); intVal = runFunc(FUNC1, intVal); printf("After running func1 intVal: %d\n", intVal); }
이 예제를 통해 포인터에 함수를 할당하여 사용할 수 있음을 확인했을 것입니다. 함수들을 필요한 순서대로 만들어 둔 뒤, 포인터를 이용하여 상황에 따라 적절한 함수가 호출되도록 만든다면 상태머신(State machine) 프로그램 등 유용하게 사용할 수 있는 부분이 정말 많습니다.
포인터를 사용하는 이유를 간단히 정리하면서 스트럭처에 대한 내용으로 넘어가겠습니다. 지금까지의 예에서 알 수 있듯이 포인터를 활용하면 메모리 번지의 활용을 쉽게 할 수 있습니다. 특정 변수의 메모리를 갖고 있는 포인터를 함수의 인수로 이용하면 모든 함수들이 동일한 번지의 변수를 활용하게 됩니다. 그리고 하나의 포인터를 이용하여 존재하는 여러개의 데이터를 할당받을 수 있기 때문에 메모리를 절약할 수 있습니다.
또한 포인터를 이용하면 정해지지 않은 함수 이름을 할당받아 사용할 수 있습니다. 이러한 기능을 이용하면 하나의 함수로 다양한 기능을 수행하게 만들 수 있습니다. 예를 들어 aFunc()를 멀티스레드로 돌아가게 만드는 threadRun()을 만든다고 했을때, 포인터로 함수 이름을 받지 않는다면 threadRun()는 aFunc()만을 위해 존재하게 될 것입니다. 하지만 포인터로 함수 이름을 받은 후, 포인터를 멀티 스레드로 돌아가게 한다면 threadRun()는 aFunc()뿐만 아니라 형식을 만족하는 어떠한 함수도 인자로 받아들여 먼티스레드로 돌아가게 만들 수 있을 것입니다.
포인터의 활용을 열심히 익혀야 된다는 것은 두말하면 잔소리일 것입니다. 포인터를 잘 활용하면 자바의 벡터 클래스와 유사한 프로그램도 만들 수 있고 여러 타입의 변수를 입력받는 함수도 만들 수 있습니다.
스트럭처
객체지향 언어들이 클래스를 이용하듯이 C언어도 클래스를 가지고 있습니다. C언어가 클래스를 만들 때 사용하는 도구가 바로 스트럭처입니다. 객체지향 언어의 클래스는 데이터와 데이터를 활용하는 메소드를 함께 제공하는 동적 클래스이지만 C언어가 제공하는 클래스는 데이터만을 제공하는 정적 클래스입니다.
물론 C도 포인터와 void 형을 함께 이용하여 스트럭처 속에 메소드를 집어넣어 동적 클래스를 만들 수 있지만 일반적인 형태의 스트럭처는 메소드가 빠진 정적 클래스가 도비니다. 객체지향언어에서 프로그램을 설계하고 구현할 때 클래스를 제일 중요시하듯이 C 프로그램을 작성할때도 스트럭처의 중요성을 잊으면 안됩니다.
먼저 스트럭처를 어떻게 만드는지 알아보겠습니다. 스트럭처를 만들려면 struct라는 예약어를 사용해야 하는데, 간단한 예를 보면 다음과 같습니다.
struct newStruct { int data1; char data2; char data3[30]; };
위에서 struct 예약어 옆에 있는 newStruct는 스트럭처의 태그(tag)로 스트럭처의 식별자(이름)가 됩니다. 그리고 스트럭처 내부의 데이터는 '{'로 시작해서 '};'로 끝이 나며 그 속에는 어떤 타입의 데이터도 들어올 수 있습니다.
심지어 또 다른 스트럭처가 포함될 수도 있습니다. 스트럭처를 만든다는 것은 기존에 존재하지 않던 데이터 형을 만드는 작업과 같습니다.
예를 들어 정수형의 데이터를 선언할 때 사용하는 것이 'int'입니다. 마찬가지로 위에서 선언한 newStruct라는 새로운 데이터를 선언할 때 사용하는 것은 'struct newStruct'입니다. int 형의 intVal라는 변수를 선언할 때 'int intVal;'라는 구문을 이용하듯이 'struct newStruct' 형의 newVal라는 변수를 선언할 때는 다음과 같이 합니다.
struct newStruct newVal;
만일 한번에 스트럭처도 정의하고 변수도 선언하려면 다음과 같이 하면 됩니다.
struct newStruct { int data1; int data2; char data3[30]; } newVal;
이제 새롭게 선언된 newVal 변수는 내부에 'int data1;', 'char data2;', 'char data3[30];' 데이터를 포함하고 있게 됩니다. 이러한 newVal 스트럭처속의 각 데이터를 접근하려면 다음과 같이 도트연산자(.)를 사용하면 됩니다.
int intVal = newVal.data1; // 또는 newVal.data1 = intVal; char cVal = newVal.data2; // 또는 newVal.data2 = cVal; strncpy(strVal, newVal.data3, 30); // 또는 strncpy(newVal.data3, strVal, 30);
스트럭처 또한 포인터로 전달이 됩니다. 포인터로 스트럭처를 할당받고 나면 스트럭처 속의 데이터에 접근할 때 도트 연산자대신 화살표 연산자(->)를 사용해야 합니다. 즉 data1에 접근하기 위해 'newVal.data1'을 사용하는 대신 'newVal->data1'을 사용해야 합니다.
스트럭처는 정적 클래스라고 했는데 객체지향 프로그램에서 클래스가 프로그램의 뼈대가 되듯이 스트럭처도 C 프로그램의 뼈대로 활용되는 경우가 많습니다. 프로젝트를 수행하는 팀들간에 모듈을 구성할 때 먼저 서로의 데이터를 주고받는 부분을 약속해야 하는데 이럴 때 가장 많이 활용하는 것이 스트럭처입니다.
데이터베이스용 프로그램을 작성한다고 했을때 DB의 테이블에 들어가는 레코드들의 조합을 이용하여 스트럭처를 작성하게 되면 스트럭처의 값들을 적절하게 채운후 DB 테이블에 저장하거나 DB 테이블의 값을 스트럭처로 받은 후 적절한 가공을 가하는 등 원하는 작업을 보다 쉽게 수행할 수 있습니다.(JAVA에서 Domain, VO 객체 개념)
네트워크 프로그램을 작성한다고 했을 때, 서로 떨어져 있는 시스템 사이에 주고받을 데이터 타입을 스트럭처로 작성하게 되면 약속된 스트럭처를 이용하여 프로토콜을 제작한 후 각각의 시스템을 독립적으로 작성할 수 있게 됩니다. 이렇게 각각 독립적으로 제작된 시스템은 향후 약속(프로토콜??)에 따른 스트럭처를 주고받기 때문에 문제없이 상호 통신을 하며 구동이 됩니다.
예를 들어, A 시스템과 B 시스템 사이의 네트워크 프로그램을 만든다고 했을 때 다음과 같은 내용을 다루기로 서로 약속을 했다고 가정해보겠습니다.
- 주고받는 패킷의 처음 4바이트는 패킷의 헤더(header)로 나머지 패킷의 내용(body)를 결정 짓습니다. 헤더에 들어갈 수 있는 숫자는 1~6이며 각각의 숫자가 담고 있는 뜻은 "연결설정요구", "연결설정응답", "연결해제요구", "연결해제응답", "명령수행요구", "명령수행응답"입니다.
- A와 B시스템은 요구와 응답을 누구든지 먼저 할 수 있으며 요구를 받은 쪽은 응답을 해야합니다. 응답을 할 때는 요구를 제대로 수행했다는 OK(1) 응답을 하거나 Not OK(2) 응답을 해야하는데 Not OK로 응답을 할 때는 요구를 제대로 수행하지 못한 이유를 응답속에 포함해서 보내야 합니다.
- 명령수행 요구를 할 때는 헤더 바로 뒤에 명령의 종류를 첨가해서 보내야 하며 명령을 수행할 때 필요한 인수를 연속적으로 붙여서 보내야 합니다.
- 명령수행 응답을 할 때, 명령수행이 제대로 되지 않았을 때는 2번의 약속처럼 Not OK(2)와 이유를 보내야 하고 수행이 제대로 되었을 때는 OK(1) 응답과 함께 명령수행 결과 데이터를 연속적으로 붙여서 보내야 합니다.
좀더 세부적인 약속(예를 들어, 명령의 종류, 명령의 인수들, 에러내용, 각 패킷의 바이트 수 등)을 정해야 보다 정확한 스트럭처를 만들 수 있겠지만 위의 내용을 바탕으로 간단한 스트럭처를 꾸민다면 다음과 같이 만들 수 있을 것입니다.
/* * 헤더부분에 들어갈 데이터의 종류를 정의한다. * 이렇게 정의를 하고 나면 숫자대신 문자열을 활용할 수 있기 때문에 * 가독성이 높아지고 실수로 인한 버그를 줄일 수 있다. * */ #define RequestConnectionOpen 1 #define ResponseOpenResult 2 #define RequestConnectionClose 3 #define ResponseCloseResult 4 #define RequestCommandRun 5 #define ResponseRunResult 6 /* * typedef 키워드를 이용하여 struct를 만들고 있다. * typedef을 이용하고 나면 이후에 스트럭처 변수를 만들 때 * "struct DeataHeaderType DataHeader" 대신 * "DataHeaderType DataHeader" 문장을 이용하여 * 데이터 타입을 선언할 수 있다. * */ typedef struct { /* 해당 메시지의 식별자의 의미한다. 4 bytes */ unsigned int messageType; /* date: YYYYMMDD 8 bytes의 Char 배열 */ TransactionID TID; } DataHeaderType; /* * DataBodyType의 스트럭처를 선언하고 있다. * 여기서 특이한 내용은 union을 이용하여 스트럭처를 만들고 있다는 점이다. * 샘플코드에는 빠져있지만 union 속에 들어있는 각각의 데이터들은 다른곳에서 * 스트럭처로 이미 선언되어진 타입들이다. * */ typedef struct { /* * union으로 선언된 내용으로, 이 속에 포함된 스트럭처는 조건에 따라 * 하나만 사용이 된다. 만일 헤더속의 messageType이 1번이면 union 속의 * ConnectionOpenType 스트럭처가 메모리에 할당되어 사용이 될 것이고, * 2번이면 OpenResultType 스트럭처가 메모리에 할당될 것이다. * union 속의 스트럭처를 참조하는 방법도 일반 struct 데이터를 참조하는 것과 * 마찬가지로 도트(.) 연산자를 활용하면 된다. * */ union { ConnectionOpenType connectionOpen; OpenResultType openResult; ConnectionCloseType connectionClose; CloseResultType closeResult; RunResultType runResult; } uData; } DataBodyType; /* * 헤더(Header)와 내용(Body)의 스트럭처를 합친 DataType형의 스트럭처를 * 선언하고 있다. 이제 스트럭처의 정의가 끝났기 때문에 각각의 시스템을 만드는 * 사람은 이러한 스트럭처를 송신했을때와 수신했을 때에 해야할 일을 함수를 이용하여 * 만들면 시스템은 성공적으로 구축이 될 것이다. * */ typedef struct { DataHeaderType dataHeader; DataBodyType dataBody; } DataType;
union은 같은 메모리 공간에 서로 종류가 틀린 데이터를 상황에 맞춰서 활용할 수 있도록 만들어준 구조체입니다. 말이 어려운데 간단한 예를 통해 이해해보겠습니다. 메모리 공간이 xx번지부터 xy번지까지 약 50바이트가 있다고 가정했을 때 A라는 조건에서는 이 속에 정수형 데이터 10개 정도를 집어넣어서 사용해야 하고 B라는 조건에서는 이 속에 문자형 데이터 40개를 넣어서 사용해야 하고, C라는 조건에서는 정수형 데이터 5개에 문자형 데이터 30개를 넣어서 사용해야 한다면 이러한 구조를 가장 제대로 표현할 수 있느 구조체가 union입니다.
지금까지 C의 내용 중에서 "함수", "포인터" 그리고 "스트럭처"에 대해 간단히 정리해 보았습니다. 이 내용이 특히 중요하다고 생각해서 뽑았지만 그밖에도 꼭 정리해두어야 할 내용들이 너무 많습니다. 마지막으로 지금까지 소개한 함수, 포인터 그리고 스트럭처가 포함된 예제 시스템을 만들어보면서 이번 절을 마무리하도록 하겠습니다.
예제 시스템
예제 시스템은 어려운 내용이 아니지만 주의깊게 봐야할 내용들을 포함하고 있습니다. 예제에서 구현하고자 하는 내용을 간단히 설명하면 다음과 같습니다.
- 데이터베이스의 테이블에서 원하는 정보를 가져오고자 하는데 이 데이터들을 담게될 스트럭처를 작성한다.
- 스트럭처에 데이터를 입력하는 작업 등을 수행하는 함수를 만든다.
- 함수의 실행을 대행할 포인터 함수를 만들고 이 함수를 이용하여 2번에서 작성한 함수들을 실행하도록 한다.
위의 내용 3번에서 포인터함수를 만든다고 했는데 이러한 함수를 만들게 되면 함수의 인자를 이용하여 원하는 함수가 실행되도록 만들 수 있습니다. 이렇게 되면 동일한 함수를 사용하면서 실제로는 여러 함수를 사용하는 것과 같은 효과를 낼 수 있습니다. 이제 위의 내용을 하나씩 만들어가면서 이해를 하도록 하겠습니다.
먼저 데이터베이스의 테이블에 있는 데이터를 담을 틀인 struct를 만들도록 합니다. 만들고자 하는 테이블은 두가지인데 하나는 DB에 저장되어 있는 프로세스리스트 테이블의 값을 가져올 PROC_LIST 스트럭처와 DB에 있는 에러리스트 테이블의 값을 가져올 ERROR_LIST 스트럭처입니다.
각각의 정의를 보면 다음과 같습니다.
/* DB에서 가져온 프로세서들의 정보를 저장할 struct */ typedef struct { char proc_name[8]; /* 프로세서 이름 */ char proc_desc[64]; /* 프로세서 설명 */ char proc_alive[2]; /* 프로세서 실행여부 */ char proc_start_time[32]; /* 프로세서 시작 시간 */ char proc_stop_time[32]; /* 프로세서 종료 시간 */ } PROC_LIST_T; /* DB에서 가져온 에러들의 정보를 저장할 struct */ typedef struct { char error_date_time[32]; /* 에러가 발생한 시간과 날짜 */ char error_cause[32]; /* 에러가 발생한 이유 */ int error_level; /* 에러 등급. Severity 숫자 이용 */ char error_mis[64]; /* 에러가 발생한 지역을 유추할 정보 */ char error_process_name[2]; /* 에러 정보를 보낸 프로세스 이름 */ char error_code[8]; /* 에러의 종류를 식별하는 정보를 제공 */ } ERROR_LIST_T;
이번에는 이들 스트럭처를 활용할 함수들을 작성하도록 합니다. 이 함수들은 DB의 값을 조회한 후 테이블 속의 데이터들을 스트럭처에 입력하고 필요한 정보로 가공하는 작업을 합니다. 프로세스 테이블을 위해 get_process_info() 함수를 작성하고, 에러 테이블을 위해 get_error_info() 함수를 작성합니다.
실제 프로그램에서는 정확한 데이터베이스 접속문, SQL Query(쿼리)문, 데이터베이스 접속 해제문 등을 이용하여 함수를 작성해야 하지만 여기선 프로토타입의 모습 정도로 대신합니다.
int get_process_info() { PROC_LIST_T proc_list; // ... ... ... rc = SQLExecDirect(hStmt, (SQLCHAR *)"select process_id, desc, ... order by process_id asc", SQL_NTS); rc = SQLBindCol(&proc_list.proc_name, sizeof(proc_list.proc_name), &l_status); // ... ... ... while((rc=SQLFetch(hStmt)) != SQL_NO_DATA_FOUND) { if(rc != SQL_SUCCESS) return -1; strcpy(proc_list.proc_name, "proc-name"); strcpy(proc_list.proc_desc, "proc-desc"); strcpy(proc_list.proc_alive, "proc-alive"); strcpy(proc_list.proc_start_time, "proc-start-time"); strcpy(proc_list.proc_stop_time, "proc-stop-time"); } if(rc != SQL_SUCCESS) return -1; return SQL_SUCCESS; }
에러코드를 담당하게될 get_error_info()도 위와 유사한 방법으로 작성합니다. 이 예제 코드에선 프로세스와 에러 테이블만 언급하였지만 실제 프로그램에서는 훨씬 많은 테이블이 존재할텐데 그 테이블마다 지금까지 작업한 것처럼 스트럭처와 함수들을 만들어야 할 것입니다.
그러면 결과적으로 여러 개의 get_XXX_info()들이 만들어질테고, 이 함수들을 필요한 곳에서 활용하면 될 것입니다. 하지만 이때 이 함수들을 그냥 실행하지 않고, 실행을 대행할 함수를 만들면 조금 더 유용하게 활용할 수 있을 것입니다. 예를 들어 runDBFunc(조건)이라는 함수를 만든 뒤, 이 함수의 인수를 통해 어떤 때는 get_process_info()를 실행시키도록 만들고 어떤때는 get_error_info()를 실행하도록 만듭니다.
이렇게 되면 함수를 사용하는 사용자들은 runDBFunc()만 사용하면 됩니다. 그리고 runDBFunc() 내부에 DB와 관련된 내용을 넣어두면 향후 유지 보수에도 많은 도움을 받게 됩니다. 예를들어 설명하면, get_XXX_info()가 10개 존재하는데 데이터베이스가 SQL 서버에서 오라클로 바뀌게 되면 10개의 함수를 모두 고쳐야 되지만 대표되는 runDBFunc()가 있는 경우 이 함수만 수정하면 됩니다. 물론 DB 테이블의 스키마에 변경이 생겨서 get_XXX_info()의 내부를 고쳐야 할 일이 있다면 그건 개별적으로 수정을 해야 합니다.
그럼, 지금까지 간단히 나열했던 내용을 직접 구현하는 방법을 보도록 하겠습니다. 먼저 get_XXX_info()들을 표현할 enum 데이터를 다음과 같이 선언합니다.
/* 함수를 가리키는 enum */ enum db_func { GET_PROCESS_INFO, /* 0 */ GET_ERROR_INFO, GET_DIST_INFO, GET_CPU_INFO, GET_DB_INFO, // ... ... ... };
그리고 함수의 실행을 대행할 포인터 함수를 선언하도록 합니다. 이 함수는 실행하고자 하는 함수들의 이름을 내부에 배열로 가지고 있게 됩니다. 이때 배열이 가질 수 있는 함수의 총 개수는 MAX_FUNC_NUM을 이용하여 선언합니다. 그리고 이 함수가 가진 배열에 값을 입력하기 위한 DBFUNC()를 선언합니다.
#define MAX_FUNC_NUM 10 #define DBFUNC(command, func) dbFuncMember[(command)]=(func) /* 함수 실행을 대행하게 될 대행 함수 */ int (*dbFuncMember[MAX_FUNC_NUM])();
이번에는 DBFUNC()를 이용하여 dbFunMember[] 배열을 세팅하도록 합니다. 이때 앞에서 선언했던 enum 데이터들과 만들어둔 함수의 이름과 매핑을 시키는 작업이 이루어집니다. 이런 일련의 작업을 수행하는 dbFuncInit()의 내용은 다음과 같습니다.
void dbFuncInit(void) { int i; for(i=0; i<MAX_FUNC_NUM; i++) dbFuncMember[i] = NULL; DBFUNC(GET_PROCESS_INFO, get_process_info); DBFUNC(GET_ERROR_INFO, get_error_info); DBFUNC(GET_DISK_INFO, get_disk_info); DBFUNC(GET_CPU_INFO, get_cpu_info); DBFUNC(GET_DB_INFO, get_db_info); // ... ... ... }
이제 마지막으로 사용자들이 이용하게 될 runDBFunc() 함수를 만들도록 하겠습니다. 이 함수는 enum에 선언된 데이터 이름(정수형 숫자)을 인수로 받은 뒤, 그 인수에 해당되는 get_XXX_info()를 포인터 함수를 이용하여 실행 시킵니다. 그리고 해당 포인터 함수를 실행시키기 전/후에 필요한 DB관련 작업을 수행합니다.
그럼, 함수의 내용을 직접 보도록 하겠습니다.
int runDBFunc(db_func) { /* DB 관련 초기 함수를 실행 */ rc = connectDBwithDSN(); /* get_XXX_info() 함수를 할당하고 실행 */ command = db_func; returnVal = (*dbFuncMember[command])(); /* 작업 수행후 DB disconnect 작업 실행 */ disconnectDB(); return returnVal; }
지금까지 스트럭처, 함수, 포인터를 이용한 실행대행 함수 등을 만드는 작업을 해 보았습니다. 마지막으로 컴파일이 가능한 전체코드를 모도록 하겠습니다. 이 코드에는 실행이 가능하도록 DB관련 내용이 모두 간단한 문장으로 대체가 되었습니다.
전체 코드를 그냥 눈으로만 보지말고 코딩, 컴파일, 실행, 테스트 및 변경을 통해 자기 것으로 완전히 소화시킬 것을 권장합니다.
#include <stdio.h> /* DB에서 가져온 프로세서들의 정보를 저장할 struct */ typedef struct{ char proc_name[8]; /* 프로세서 이름 */ char proc_desc[64]; /* 프로세서 설명 */ char proc_alive[2]; /* 프로세서 실행여부 */ char proc_start_time[32]; /* 프로세스 시작 시간 */ char proc_stop_time[32]; /* 프로세서 종료 시간 */ } PROC_LIST_T; PROC_LIST_T proc_list; /* DB에서 가져온 에러들의 정보를 저장할 struct */ typedef struct{ char error_date_time[32]; /* 에러가 발생한 시간과 날짜 */ char error_cause[32]; /* 에러가 발생한 이유 */ int error_level; /* 에러 등급. Severity 숫자 이요 */ char error_mis[64]; /* 에러가 발생한 지역을 유추할 정보 */ char error_process_name[2]; /* 에러 정보를 보낸 프로세스 이름 */ char error_code[8]; /* 에러의 종류를 식별하는 정보를 제공 */ } ERROR_LIST_T; ERROR_LIST_T error_list; /* 프로세스 테이블의 값을 조회하는 함수 */ int get_process_info() { printf("get_process_info() 함수 실행!\n"); strcpy(proc_list.proc_name, "proc-name"); strcpy(proc_list.proc_desc, "proc-desc"); strcpy(proc_list.proc_alive, "proc-alive"); strcpy(proc_list.proc_start_time, "proc-start-time"); strcpy(proc_list.proc_stop_time, "proc-stop-time"); printf("프로세스 테이블에서 데이터 조회 끝!\n"); return 1; } /* 에러 테이블의 값을 조회하는 함수 */ int get_error_info() { printf("get_error_info() 함수 실행!\n"); strcpy(error_list.error_date_time, "date-time"); strcpy(error_list.error_cause, "error-cause"); error_list.error_level = 1; strcpy(error_list.error_mis, "error-mis"); strcpy(error_list.error_process_name, "ko"); strcpy(error_list.error_code, "code"); printf("에러 테이블에서 데이터 조회 끝!\n"); return 1; } #define MAX_FUNC_NUM 2 #define DBFUNC(command, func) dbFuncMember[(command)]=(func) /* 함수 실행을 대행하게 될 대행 함수 */ int (*dbFuncMember[MAX_FUNC_NUM])(); /* 함수를 가리키는 enum */ enum db_func { GET_PROCESS_INFO, /* 0 */ GET_ERROR_INFO }; /* enum 데이터와 실제 함수이름 매핑 */ void dbFuncInit(void) { int i; for(i=0; i < MAX_FUNC_NUM; i++) dbFuncMember[i] = NULL; DBFUNC(GET_PROCESS_INFO, get_process_info); DBFUNC(GET_ERROR_INFO, get_error_info); } int runDBFunc(db_func) { int returnVal; /* DB 관련 초기 함수를 실행 */ printf("\nDB와 연결을 설정합니다.\n"); /* get_XXX_info() 함수를 할당하고 실행 */ returnVal = (*dbFuncMember[db_func])(); /* 작업 수행후 DB disconnect 작업 실행 */ printf("DB와 연결을 해제합니다.\n\n"); return returnVal; } int main() { dbFuncInit(); runDBFunc(GET_PROCESS_INFO); // 또는 runDBFunc(0); runDBFunc(GET_ERROR_INFO); // 또는 runDBFunc(1); }
[root@dev-web c_programming]# ./RunDBFunc.out
DB와 연결을 설정합니다.
get_process_info() 함수 실행!
프로세스 테이블에서 데이터 조회 끝!
DB와 연결을 해제합니다.
DB와 연결을 설정합니다.
get_error_info() 함수 실행!
에러 테이블에서 데이터 조회 끝!
DB와 연결을 해제합니다.
라이브러리
함수나 클래스를 만들 때 내용은 없고 선언만 존재하는 프로토타입이란 것이 있습니다. 프로토타입은 정의는 빠지고 선언만 존재하는 형태인데, 예를 들어 다음과 같은 함수가 있다고 해보겠습니다.
int testFunc(int intVal, char cVal) { intVal...; cVal...; }
이때 이 함수의 프로토타입은 다음과 같습니다.
int testFunc(int intVal, char cVal);
일반적으로 함수나 클래스의 메소드를 작성할 때 이들의 프로토타입만 모아서 별도의 헤더 파일을 만든 후 다른 개발자나 일반 사용자에게 헤더 파일만 읽기 가능한 형태로 배포를 합니다. 그리고 함수의 body부분은 별도로 컴파일을 한 후에 오브젝트 파일이나 라이브러리 형태로 배포합니다.
헤더 파일과 라이브러리를 받거나 구매한 개발자는 헤더파일 속의 프로토타입을 통해 함수의 리턴형과 인수를 정확히 알게되며 헤더 파일 속의 설명을 이용하여 어떻게 사용하는 것인지 무슨 작업을 하는 함수인지를 파악하게 됩니다.
헤더 파일을 만들 때는 확장자로 '.h'를 이용하는데 특정 함수를 이용하기 위해서는 그 함수의 프로토타입이 선언된 헤더 파일을 사용하고자 하는 파일에 포함시키는(include) 작업이 필요합니다. 헤더 파일을 포함(include)시킨 후 헤더 파일 속의 프로토타입대로 함수를 이용하여 코드를 작성하고 컴파일을 하게 되면 컴파일러는 프로토타입대로 메모리를 할당하고 함수의 호출이 가능하도록 코드를 재배치한 오브젝트 파일을 만들게 됩니다.
오브젝트 파일 작성이 끝나고 나면 링커를 통해 프로토타입에 대한 라이브러리를 방금 전에 작성한 오브젝트 파일과 정적으로 결합을 시키거나 동적으로 결합을 시키게 됩니다. 만일 이 단계에서 라이브러리 속에 필요한 함수의 body가 빠졌거나 형식이 맞지 않거나 하면 링크 에러가 발생하게 됩니다. 에러없이 링크작업이 끝이 나면 실행파일이 만들어집니다.
마지막으로 실행 파일을 이용하여 프로그램을 실행하면 헤더 파일속에 있던 함수의 프로토타입이 호출되고 그러면 결합된 라이브러리 속의 해당 함수의 body가 실행됩니다. 지금가지 설명한 내용을 바탕으로 헤더 파일과 라이브러리를 만들고 그 파일들을 이용하여 프로그램을 작성한 예를 보도록 하겠습니다.
먼저, 작성하는 예제에 대해 간단히 설명하면 스트럭처(명령과 결과를 담당하게 될)를 세팅하는 간단한 함수를 작성한 뒤, 그 함수를 라이브러리로 만듭니다. 그런다음 그 라이브러리를 활용할 main() 함수를 작성한 뒤, 함께 컴파일하여 실행파일을 만들고 테스트를 합니다. 그럼, 먼저 스트럭처를 정의한 헤더 파일을 먼저 보도록 하겠습니다.
#define REQUEST 1 #define RESPONSE 2 /* request를 담은 스트럭처 */ typedef struct { int reqType; char reqData[10]; } RequestType; /* response를 담은 스트럭처 */ typedef struct { int isDone; char resData[30]; } ResonseType; /** * request 또는 response를 담게될 * message 스트럭쳐 */ typedef struct { /* messageType에 따라 union속의 타입이 결정 */ unsigned int messageType; // union 타입으로 상황에 따라 RequestType 형의 스트럭처로 활용될 수도 있고 // ResponseType형의 스트럭처로 활용될 수도 있다. union { /* messageType = REQUEST인 경우 */ RequestType request; /* messageType = RESPONSE인 경우 */ ResponseType response; } uType; } MessageType;
msgType.h 속에 정의된 스트럭처(MessageType)는 이전에 소개했던 스트럭처와 유사한 형태를 가지고 있습니다.
이번에는 MessageType 형의 스트럭처를 set하고 show하는 함수로 이루어진 makeMsg.c 파일을 보도록 하겠습니다. 나중에 이 파일을 라이브러리로 만든 후, 다른 파일이 활용하게 됩니다.
#include <stdio.h> #include "msgType.h" /* * 포인터로 들어온 message의 messageType부터 확인을 한다. * * 메시지 타입이 REQUEST이면 message 속의 union 데이터 중 requestType 형의 * 스트럭처에 데이터를 세팅한 후 포인터를 리턴한다. * * 마찬가지로 RESPONSE 타입인 경우에는 responseType형의 스트럭처를 이용한다. * * 만일 둘중 아무 타입도 아니면 0을 리턴한다. * */ MessageType* setMsg(MessageType *message) { if(message->messageType = REQUEST) { message->uType.request.reqType = 1; strcpy(message->uType.request.reqData, "Request.\0"); return message; } else if(message->messageType == RESPONSE) { message->uType.response.isDone = 1; strcpy(message->uType.response.resData, "Result 1.Data:3, 2.Number:10...\0"); return message; } return 0; } /* * showMsg()는 message 포인터를 인수로 받은 후, * 내부의 데이터를 화면에 출력한다. 이때도 messageType을 확인한 후 * union 속의 어떤 스트럭처를 활용할지 결정한다. * * 확인이 끝나면 해당 스트럭처 속의 데이터를 화면에 출력한다. * */ void showMsg(MessageType *message) { if(message->messageType == REQUEST) { printf("Request Type: %d\n", message->uType.request.reqType); printf("Request Data: %s\n", message->uType.request.reqData); } else if(message->messageType == RESPONSE) { printf("Response is done: %d\n", message->uType.response.isDone); printf("Response Data: %s\n", message->uType.response.resData); } }
마지막으로 위의 함수를 활용할 main()을 보도록 하겠습니다. 메인함수가 포함된 파일의 이름은 useMsg.c로 아래와 같습니다.
#include <stdio.h> #include "msgType.h" int main(int argc, char* argv[]) { MessageType message; if(argc < 2) { printf("메시지 타입. request:req, response:res\n"); return 0; } if(!strncmp(argv[1], "req", 3)) // strncmp: 길이만큼의 문자열 비교 { message.messageType = REQUEST; setMsg(&message); } else if(!strncmp(argv[1], "res", 3)) { message.messageType = RESPONSE; setMsg(&message); } else { printf("Something is wrong!\n"); } showMsg(&message); return 0; }
위의 main() 함수를 자세히 보면 main()의 인자를 활용한 것을 볼수 있습니다. argc와 argv로 이름붙은 인수들이 그것인데, 이 인수들은 명령입력줄에서 프로그램을 실행시키면서 같이 입력한 인수를 사용하는 것입니다.
예를 들어 아래와 같이 실행했다고 가정해보겠습니다.
$ useMsg res req
그러면 총 3개의 인수가 입력이 되는데 첫번째 인수는 명령어 이름인 'useMsg'이고, 두번째는 'res', 세 번째는 'req'가 됩니다. 이런 경우에 main() 함수에서 사용하는 인수 argc에는 3이 할당이 되며 argv[0]에는 useMsg가 할당이 되고, argv[1]에는 res, argv[2]에는 req가 할당이 됩니다.
다시 예제를 보면 7번 라인에서 명령어 인수의 개수를 확인하고 2개가 안되면 실행을 종료합니다. 만일 2개가 넘으면 argv[1]에 할당된 내용이 'req'인지 'res'인지 확인을 하게 됩니다. 확인을 한 후 'req'이면 messageType을 'REQUEST'로 할당한 후, setMsg() 함수를 호출하게 되고 함수의 호출이 끝나면 28번 라인에서 보듯이 showMsg() 함수를 호출한 후 실행을 끝내게 됩니다.
그럼, 예제의 내용과 소스 분석이 끝났으니 라이브러리를 만들고 실행 파일을 만들어 보겠습니다. 먼저 라이브러리로 만들고자 하는 makeMsg.c 파일의 오브젝트 파일부터 만들겠습니다. 오브젝트 파일을 만들기 위해서는 아래와 같이 컴파일을 해보겠습니다. 이때 -c 옵션을 이용하도록 합니다.
$ gcc -c makeMsg.c
컴파일이 제대로 끝났으면 확장자로 'o'를 가진 makeMsg.o 파일이 생성될 것입니다. 이번에는 생성된 오브젝트 파일을 이용하여 라이브러리를 만들도록 합니다. 이때 사용하는 명령어는 ar로 라이브러리 작성에 사용됩니다. 아래와 같이 ar 명령어와 옵션 그리고 생성하고자 하는 라이브러리의 이름, 마지막으로 오브젝트 파일들의 이름들을 나열한 후 실행합니다.
$ ar crv libMsg.a makeMsg.o
지금은 하나의 오브젝트 파일만 이용하여 라이브러리를 작성했지만 보통은 여러개의 오브젝트 파일을 이용하여 라이브러리를 만듭니다. 사실 하나의 오브젝트 파일로 라이브러리를 만드는 것은 별로 의미가 없습니다. 라이브러리 파일은 유사한 목적을 수행하는 오브젝트 파일들을 하나로 묶어서 내부적으로 활용하거나 배포할 때 매우 유용합니다.
ar 명령어를 실행했으면 ls를 이용하여 libMsg.a 파일이 생성되었는지 확인을 합니다. 라이브러리 파일이 생겼으면 이제 이 파일을 이용하여 실행파일을 만들어보겠습니다. 이때 필요한 파일은 main() 함수를 가지고 있는 useMsg.c 파일인데 아래와 같이 useMsg 이름을 가진 실행파일을 만들도록 하겠습니다.
$ gcc -o useMsg useMsg.c libMsg.a
이제 실행 파일 useMsg가 만들어졌는지 확인하고 실행 및 테스트를 해보도록 하겠습니다. 'useMsg req' 또는 'useMsg res'를 실행시켜 화면에 제대로 된 결과가 나오는지 확인해보겠습니다.
라이브러리 파일을 이용하여 실행 파일을 만들 때 위의 방법외에 컴파일러의 옵션을 이용하는 방법이 있습니다. 이때 사용하는 옵션이 '-L'과 '-l'인데 -L의 경우에는 사용하고자 하는 라이브러리가 포함된 디렉토리명을 명시하는 옵션이고, -l은 라이브러리의 이름을 적어주는 옵션입니다. 이때 라이브러리의 이름은 lib라는 말과 확장자 '.a'를 생략하여 사용할 수 있습니다. 즉, libMsg.a의 경우 그냥 Msg라고 쓰면 됩니다.
실제 예를 보겠습니다. 위에서 사용했던 "gcc -o useMsg useMsg.c libMsg.a" 명령을 아래와 같이 대체할 수 있습니다.
$ gcc -o useMsg useMsg.c -L. -lMsg
안정성이 검증되고 성능이 뛰어난 함수들을 만들었으면 라이브러리 형태로 만들어두기를 권합니다. 그리고 다른 사람에게 이를 배포할 때는 라이브러리 속에 있는 함수의 헤더파일과 라이브러리 파일을 전달하도록 합니다. 이것이 자신만의 코드를 만들고 관리하는 작지만 큰 첫걸음이 될 것입니다.
'프로그래밍(TA, AA) > C C++' 카테고리의 다른 글
[Pro*C] Pro*C 프로그램의 구성 (0) | 2017.09.09 |
---|---|
[Pro*C] Pro*C 소개와 기본 특징 및 오류 진단 (0) | 2017.09.09 |
[C/C++] C와 C++을 동시에 배워보자 (0) | 2017.09.01 |
[시스템프로그래밍] 주요 정리 (0) | 2017.08.18 |
[통신프로그래밍] IPC(메시지큐, 공유 메모리, 세마포어) (0) | 2017.08.15 |