Ver3.5 C언어 리팩터링과 레거시 코드 현대화 유지보수 전략 가이드

C언어 리팩터링과 레거시 코드 현대화 유지보수 전략 가이드
낡은 코드를 살려내는 현실적인 방법들 🔧 | 레거시의 늪에서 탈출하자!
야 솔직히 말해서, C언어로 짜인 레거시 코드 보면 진짜 현타 오지 않음? 😅
변수명은 a, b, tmp2... 주석은 당연히 없고, 함수 하나가 500줄짜리에 goto문 난무하는 그 느낌.
근데 이게 현실임. 임베디드 시스템, 통신 장비, 금융 인프라, 항공우주 소프트웨어까지 — 세상의 핵심 인프라 상당수가 아직도 C언어로 돌아가고 있거든.
그래서 오늘은 C언어 레거시 코드를 어떻게 현대화하고 리팩터링할 것인가에 대해 진짜 실용적으로 파헤쳐볼 거야.
이론만 늘어놓는 글 말고, 실제로 써먹을 수 있는 전략 위주로! 🔥
먼저 "레거시 코드"가 뭔지 정확히 짚고 가자.
마이클 페더스(Michael Feathers)가 쓴 Working Effectively with Legacy Code에서 내린 정의가 유명한데:
테스트가 없는 코드 = 레거시 코드
근데 C언어 맥락에서는 좀 더 넓게 봐야 해. 단순히 테스트 없는 코드만이 아니라:
if (status == 7) 같은 코드. 7이 뭔지 아무도 모름.이런 코드가 왜 문제냐고? 당장 돌아가니까 괜찮은 거 아니냐고? 🤔
아니 그게 아니야. 문제는 변경 비용이야.
버그 하나 고치려다가 다른 곳 3개가 터짐. 기능 하나 추가하려면 코드 전체를 이해해야 함. 새 개발자가 온보딩하는 데 6개월 걸림. 이게 바로 기술 부채(Technical Debt)가 쌓이는 방식이야.
실제 통계를 보면, 소프트웨어 유지보수 비용이 전체 개발 비용의 60~80%를 차지한다는 연구 결과가 있어 (Boehm, 1981; Lientz & Swanson, 1980).
그리고 그 유지보수 비용의 상당 부분이 바로 레거시 코드 이해하고 수정하는 데 들어가는 거임.
C언어는 특히 심한데, 언어 자체가 프로그래머에게 엄청난 자유를 주기 때문에 — 포인터 마음대로, 메모리 마음대로, 타입 캐스팅 마음대로 — 나쁜 코드를 쓰기가 너무 쉬워. 😭
리팩터링 시작하기 전에 무작정 코드 고치면 안 돼. 진짜로.
이게 레거시 코드 작업에서 제일 많이 하는 실수임. "이 부분 이상해 보이니까 고쳐야지" → 시스템 다운 → 야근 → 이직 고려 ㅋㅋㅋ
먼저 코드를 이해하는 단계가 필수야.
C언어 레거시 코드 분석에 쓸 수 있는 도구들이 꽤 있어:
| 도구 | 용도 | 특징 |
|---|---|---|
| cppcheck | 정적 분석 | 무료, 메모리 누수·미정의 동작 탐지 |
| Clang Static Analyzer | 정적 분석 | LLVM 기반, 정밀도 높음 |
| Splint | 정적 분석 | 주석 기반 계약 검증 |
| Understand | 코드 이해 | 의존성 그래프, 메트릭 분석 (유료) |
| Doxygen | 문서화 | 코드에서 자동 문서 생성 |
| gcov / lcov | 커버리지 | 어떤 코드가 실행되는지 파악 |
리팩터링 우선순위를 정하려면 객관적인 수치가 필요해. 주요 메트릭들:
순환 복잡도 (Cyclomatic Complexity)
맥케이브(McCabe)가 1976년에 제안한 개념. 함수 내의 독립적인 경로 수를 측정해.
공식:
CC = E - N + 2P (E=엣지, N=노드, P=연결 컴포넌트)CC가 10 이상이면 복잡한 거, 20 이상이면 테스트하기 매우 어려운 수준이야.
함수 길이 (Lines of Code per Function)
함수 하나가 50줄 넘어가면 분리 고려. 200줄 넘으면 무조건 분리해야 해.
결합도 (Coupling)
모듈 간 의존성. 높을수록 변경이 어려움.
응집도 (Cohesion)
모듈 내 요소들이 얼마나 관련 있는지. 높을수록 좋음.
레거시 코드에서 어떤 파일이 어떤 파일을 include하는지, 어떤 함수가 어떤 함수를 호출하는지 맵을 그려봐.
이게 있어야 "이 부분 건드리면 어디까지 영향 가는지" 알 수 있거든.
gcc -MM 옵션으로 헤더 의존성을 뽑을 수 있고, ctags나 cscope로 함수 호출 관계를 파악할 수 있어.
레거시 코드에는 종종 "죽은 코드(Dead Code)"가 있어. 실제로 호출되지 않는 함수들. 이걸 먼저 제거하면 분석 범위가 줄어들어서 훨씬 편해져. 근데 확실히 죽은 코드인지 100% 확인하고 제거해야 해. 혹시 동적으로 호출되는 코드일 수도 있거든.
C언어 레거시 코드 리팩터링에서 제일 중요한 원칙이 뭔지 알아?
테스트 먼저, 리팩터링 나중
근데 문제가 있어. 레거시 코드는 테스트하기 어렵게 설계되어 있다는 거야. 전역 변수 의존, 하드코딩된 I/O, 거대한 함수들... 이게 다 테스트를 방해하는 요소들이야.
그래서 C언어에서 테스트를 어떻게 추가하는지 알아보자.
Unity — 임베디드 시스템에서 많이 씀. 가볍고 단순. ThrowTheSwitch에서 만든 거야.
CUnit — JUnit 스타일. 비교적 사용하기 쉬움.
Check — POSIX 환경에서 많이 씀. 포크(fork)로 테스트 격리.
cmocka — 모킹(Mocking) 지원. 의존성 주입이 어려운 C코드에 유용.
Criterion — 현대적인 API. 자동 테스트 발견 기능.
Unity 예시를 보면 이런 식이야:
/* test_calculator.c */
#include "unity.h"
#include "calculator.h"
void setUp(void) {
/* 각 테스트 전에 실행 */
}
void tearDown(void) {
/* 각 테스트 후에 실행 */
}
void test_add_positive_numbers(void) {
TEST_ASSERT_EQUAL_INT(5, add(2, 3));
}
void test_add_negative_numbers(void) {
TEST_ASSERT_EQUAL_INT(-1, add(2, -3));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_add_positive_numbers);
RUN_TEST(test_add_negative_numbers);
return UNITY_END();
}
레거시 코드는 테스트하기 어려운 구조야. 대표적인 문제들과 해결책:
/* 나쁜 예 - 테스트 불가 */
int g_status = 0;
void process_data(void) {
if (g_status == 1) {
/* 뭔가 함 */
}
}
해결: 매개변수로 상태 전달
/* 좋은 예 - 테스트 가능 */
void process_data(int status) {
if (status == 1) {
/* 뭔가 함 */
}
}
/* 나쁜 예 */
void read_config(void) {
FILE *f = fopen("/etc/myapp.conf", "r");
/* ... */
}
해결: 파일 경로나 FILE 포인터를 매개변수로
/* 좋은 예 */
int read_config(FILE *config_file) {
/* config_file로 읽기 */
/* 테스트에서 tmpfile()이나 메모리 스트림 사용 가능 */
}
C에서 모킹(Mocking)하는 방법 중 하나야.
/* 함수 포인터로 의존성 주입 */
typedef int (*send_func_t)(const char *data, int len);
int process_and_send(const char *data, send_func_t send_fn) {
/* 처리 후 send_fn 호출 */
return send_fn(data, strlen(data));
}
/* 테스트에서 */
int mock_send(const char *data, int len) {
/* 실제로 보내지 않고 검증만 */
return len;
}
void test_process_and_send(void) {
int result = process_and_send("hello", mock_send);
TEST_ASSERT_EQUAL_INT(5, result);
}
레거시 코드에 테스트를 추가할 때 처음부터 100% 커버리지를 목표로 하면 안 돼. 현실적으로 불가능하고, 그럴 필요도 없어.
현실적인 접근법:
1단계: 가장 중요한 비즈니스 로직 먼저 (핵심 기능 50% 커버리지 목표)
2단계: 버그가 자주 발생하는 부분 (히스토리 보고 파악)
3단계: 변경이 잦은 부분 (git log로 파악)
4단계: 점진적으로 커버리지 확대
gcc -fprofile-arcs -ftest-coverage -o myapp myapp.c실행 후:
gcov myapp.cHTML 리포트:
lcov --capture --directory . --output-file coverage.infogenhtml coverage.info --output-directory out
이제 진짜 실전이야. C언어에서 자주 쓰이는 리팩터링 기법들을 구체적으로 살펴보자. 🔥
제일 쉽고 효과 큰 리팩터링이야. 근데 C언어에서는 조심해야 해 — 헤더 파일에 공개된 함수명 바꾸면 API 호환성이 깨지거든.
int proc(int x, int y, int t) {
int r = 0;
if (t == 1) r = x + y;
else if (t == 2) r = x - y;
else if (t == 3) r = x * y;
return r;
}
After (리팩터링 후)
typedef enum {
OPERATION_ADD = 1,
OPERATION_SUBTRACT = 2,
OPERATION_MULTIPLY = 3
} OperationType;
int calculate(int operand_a, int operand_b, OperationType operation) {
int result = 0;
if (operation == OPERATION_ADD) result = operand_a + operand_b;
else if (operation == OPERATION_SUBTRACT) result = operand_a - operand_b;
else if (operation == OPERATION_MULTIPLY) result = operand_a * operand_b;
return result;
}
이름만 바꿨는데 코드가 훨씬 읽기 쉬워졌지? 😊
긴 함수를 작은 함수들로 쪼개는 거야. C언어에서 제일 많이 쓰는 리팩터링 기법 중 하나.
void process_order(Order *order) {
/* 주문 유효성 검사 - 50줄 */
if (order == NULL) return;
if (order->quantity <= 0) { /* ... */ }
if (order->price < 0) { /* ... */ }
/* ... 더 많은 검사들 ... */
/* 재고 확인 - 80줄 */
int stock = get_stock(order->product_id);
if (stock < order->quantity) { /* ... */ }
/* ... */
/* 결제 처리 - 100줄 */
/* ... */
}
After (함수 추출 후)
static int validate_order(const Order *order) {
if (order == NULL) return -1;
if (order->quantity <= 0) return -2;
if (order->price < 0) return -3;
/* ... */
return 0;
}
static int check_inventory(const Order *order) {
int stock = get_stock(order->product_id);
if (stock < order->quantity) return -1;
/* ... */
return 0;
}
static int process_payment(const Order *order) {
/* ... */
return 0;
}
void process_order(Order *order) {
if (validate_order(order) != 0) return;
if (check_inventory(order) != 0) return;
if (process_payment(order) != 0) return;
}
훨씬 읽기 쉽고, 각 함수를 독립적으로 테스트할 수 있어!
C언어 레거시 코드의 최대 악당 — 전역 변수. 이걸 구조체로 묶어서 관리하는 게 핵심이야.
/* globals.h */
extern int g_connection_count;
extern int g_max_connections;
extern char g_server_ip[64];
extern int g_port;
extern int g_timeout_ms;
extern int g_is_connected;
/* 여러 파일에서 이 변수들을 직접 수정 */
After (구조체로 캡슐화)
/* server_context.h */
typedef struct {
int connection_count;
int max_connections;
char server_ip[64];
int port;
int timeout_ms;
int is_connected;
} ServerContext;
/* 접근자 함수 제공 */
ServerContext* server_context_get(void);
int server_context_init(const char *ip, int port, int max_conn);
void server_context_destroy(void);
/* server_context.c */
static ServerContext g_ctx = {0}; /* 단 하나의 전역 변수 */
ServerContext* server_context_get(void) {
return &g_ctx;
}
이렇게 하면 상태 관리가 훨씬 명확해지고, 나중에 멀티스레드 환경에서 뮤텍스 추가하기도 쉬워져.
/* Before - 화살표 코드 (Arrow Code) */
int process(Data *data) {
int result = -1;
if (data != NULL) {
if (data->is_valid) {
if (data->size > 0) {
if (data->buffer != NULL) {
/* 실제 처리 */
result = do_work(data);
}
}
}
}
return result;
}
/* After - 조기 반환 패턴 */
int process(Data *data) {
if (data == NULL) return -1;
if (!data->is_valid) return -2;
if (data->size <= 0) return -3;
if (data->buffer == NULL) return -4;
return do_work(data);
}
훨씬 읽기 쉽지? 이걸 "Guard Clause" 패턴이라고도 해.
/* Before */
void set_mode(int mode) {
if (mode == 0) init_normal();
else if (mode == 1) init_debug();
else if (mode == 2) init_test();
else if (mode == 99) emergency_shutdown();
}
/* After */
typedef enum {
MODE_NORMAL = 0,
MODE_DEBUG = 1,
MODE_TEST = 2,
MODE_EMERGENCY = 99
} AppMode;
void set_mode(AppMode mode) {
switch (mode) {
case MODE_NORMAL: init_normal(); break;
case MODE_DEBUG: init_debug(); break;
case MODE_TEST: init_test(); break;
case MODE_EMERGENCY: emergency_shutdown(); break;
default:
/* 알 수 없는 모드 처리 */
break;
}
}
C언어 레거시 코드에서 포인터 관련 버그가 제일 많아. NULL 체크 없이 포인터 역참조, 해제 후 사용(Use After Free), 이중 해제(Double Free) 등등.
이걸 개선하는 패턴들:
/* 안전한 free 매크로 */
#define SAFE_FREE(ptr) do { \
if ((ptr) != NULL) { \
free(ptr); \
(ptr) = NULL; \
} \
} while(0)
/* 사용 */
char *buffer = malloc(1024);
/* ... 사용 ... */
SAFE_FREE(buffer); /* 해제 후 NULL로 설정 */
/* 이제 buffer를 실수로 다시 사용해도 NULL 체크로 잡힘 */
소유권 명확화 (주석으로)
/* 소유권 전달 명확히 표시 */
/* 반환된 포인터는 호출자가 free() 해야 함 */
char* create_message(const char *template, int id);
/* 소유권 없음 - 해제하면 안 됨 */
/* 반환된 포인터는 내부 버퍼를 가리킴 */
const char* get_error_string(int error_code);
레거시 C코드 중 상당수가 C89/C90 표준으로 작성되어 있어. 근데 C99, C11, C17에서 추가된 기능들이 코드를 훨씬 안전하고 읽기 쉽게 만들어줘.
물론 컴파일러 지원 여부 확인이 먼저야! 임베디드 환경이면 특히 조심해야 해.
/* C89: 함수 시작에만 선언 가능 */
void old_style(void) {
int i, result, temp;
/* ... 100줄 후에 사용 ... */
for (i = 0; i < 10; i++) { /* ... */ }
}
/* C99: 사용하는 곳 근처에 선언 */
void new_style(void) {
for (int i = 0; i < 10; i++) {
int result = compute(i);
/* result 바로 사용 */
}
}
② 지정 초기화자 (Designated Initializers)
/* C89 */
Config cfg;
cfg.width = 800;
cfg.height = 600;
cfg.depth = 32;
/* C99 - 훨씬 명확! */
Config cfg = {
.width = 800,
.height = 600,
.depth = 32
};
③ 가변 길이 배열 (VLA) - 주의해서 사용
/* C99 */
void process(int n) {
int buffer[n]; /* 스택에 동적 크기 배열 */
/* 단, 스택 오버플로우 위험 있으니 크기 제한 필요 */
}
④ bool 타입 (stdbool.h)
#include <stdbool.h>
/* C89 */
int is_valid = 1; /* 0 or 1 */
/* C99 */
bool is_valid = true; /* 의도가 명확함 */
⑤ 고정 크기 정수 타입 (stdint.h)
#include <stdint.h>
/* C89 - 플랫폼마다 크기가 다를 수 있음 */
unsigned long counter; /* 32bit? 64bit? */
/* C99 - 명확한 크기 */
uint32_t counter; /* 항상 32비트 */
uint64_t big_num; /* 항상 64비트 */
int8_t small_val; /* 항상 8비트 부호있는 정수 */
#include <assert.h>
/* 구조체 크기가 예상과 다르면 컴파일 에러 */
_Static_assert(sizeof(PacketHeader) == 16,
"PacketHeader 크기가 16바이트여야 합니다");
/* 플랫폼 가정 검증 */
_Static_assert(sizeof(int) == 4,
"이 코드는 32비트 int를 가정합니다");
② _Noreturn — 반환하지 않는 함수 표시
#include <stdnoreturn.h>
noreturn void fatal_error(const char *msg) {
fprintf(stderr, "치명적 오류: %s\n", msg);
exit(EXIT_FAILURE);
}
③ 익명 구조체/공용체
/* C11 */
typedef struct {
int type;
union {
int int_val;
float float_val;
char str_val[32];
}; /* 익명 공용체 - 멤버에 직접 접근 가능 */
} Value;
Value v;
v.type = 1;
v.int_val = 42; /* v.u.int_val 대신 직접 접근 */
레거시 C코드의 보안 취약점 1위가 바로 버퍼 오버플로우야. strcpy, sprintf, gets 같은 함수들이 주범이지.
/* 위험 → 안전 */
gets(buffer) → fgets(buffer, sizeof(buffer), stdin)
strcpy(dst, src) → strncpy(dst, src, sizeof(dst)-1); dst[sizeof(dst)-1] = '\0';
또는 strlcpy(dst, src, sizeof(dst)) /* BSD/macOS */
strcat(dst, src) → strncat(dst, src, sizeof(dst)-strlen(dst)-1)
sprintf(buf, fmt, ...) → snprintf(buf, sizeof(buf), fmt, ...)
scanf("%s", buf) → scanf("%255s", buf) /* 크기 제한 */
안전한 문자열 복사 래퍼 함수 만들기
/* 안전한 문자열 복사 - 항상 NULL 종료 보장 */
static int safe_strcpy(char *dst, size_t dst_size, const char *src) {
if (dst == NULL || src == NULL || dst_size == 0) return -1;
size_t src_len = strlen(src);
if (src_len >= dst_size) {
/* 잘림 발생 - 로그 남기기 */
src_len = dst_size - 1;
}
memcpy(dst, src, src_len);
dst[src_len] = '\0';
return (int)src_len;
}
strcpy_s, sprintf_s 같은 함수들이 C11 Annex K에 정의되어 있어. 근데 이게 선택적 구현이라서 모든 컴파일러가 지원하지 않아. GCC/Clang은 기본적으로 지원 안 함. 그래서 이식성이 중요한 코드에서는 직접 래퍼 함수를 만드는 게 더 안전해.
함수 수준의 리팩터링을 넘어서, 더 큰 그림을 봐야 할 때도 있어.
특히 레거시 C 시스템이 수십만 줄 규모라면 아키텍처 자체를 개선해야 해.
C언어에서 모듈화는 헤더 파일(.h)과 소스 파일(.c)의 쌍으로 이루어져.
레거시 코드는 이 경계가 무너져 있는 경우가 많아 — 헤더에 구현이 있거나, 전역 변수가 여러 파일에 퍼져 있거나.
/* sensor.h - 공개 인터페이스만 노출 */
#ifndef SENSOR_H
#define SENSOR_H
#include <stdint.h>
#include <stdbool.h>
/* 불투명 포인터 (Opaque Pointer) - 내부 구현 숨기기 */
typedef struct Sensor Sensor;
/* 생성/소멸 */
Sensor* sensor_create(int device_id);
void sensor_destroy(Sensor *sensor);
/* 동작 */
bool sensor_read(Sensor *sensor, float *out_value);
bool sensor_calibrate(Sensor *sensor);
#endif /* SENSOR_H */
/* sensor.c - 내부 구현 */
#include "sensor.h"
#include <stdlib.h>
struct Sensor {
int device_id;
float calibration_offset;
bool is_initialized;
/* 내부 상태들 - 외부에서 직접 접근 불가 */
};
Sensor* sensor_create(int device_id) {
Sensor *s = calloc(1, sizeof(Sensor));
if (s == NULL) return NULL;
s->device_id = device_id;
s->is_initialized = false;
return s;
}
void sensor_destroy(Sensor *sensor) {
free(sensor);
}
이 패턴을 "불투명 포인터(Opaque Pointer)" 또는 "핸들(Handle)" 패턴이라고 해. C에서 캡슐화를 구현하는 핵심 방법이야!
C에서 다형성을 구현하는 방법이야. 레거시 코드에서 하드코딩된 조건문들을 함수 포인터 테이블로 교체할 수 있어.
/* 인터페이스 정의 */
typedef struct {
int (*open)(const char *path);
int (*read)(int fd, void *buf, size_t len);
int (*write)(int fd, const void *buf, size_t len);
void (*close)(int fd);
} FileOps;
/* 구현체 1: 실제 파일 시스템 */
static const FileOps real_file_ops = {
.open = real_open,
.read = real_read,
.write = real_write,
.close = real_close
};
/* 구현체 2: 테스트용 메모리 파일 시스템 */
static const FileOps mock_file_ops = {
.open = mock_open,
.read = mock_read,
.write = mock_write,
.close = mock_close
};
/* 사용 */
void process_file(const FileOps *ops, const char *path) {
int fd = ops->open(path);
/* ... */
ops->close(fd);
}
/* 프로덕션 */
process_file(&real_file_ops, "/data/input.bin");
/* 테스트 */
process_file(&mock_file_ops, "test_input");
레거시 C 프로젝트는 Makefile이 엉망이거나, 아예 IDE 프로젝트 파일만 있는 경우도 있어.
현대적인 빌드 시스템으로 마이그레이션하면 CI/CD 연동이 훨씬 쉬워져.
| 빌드 시스템 | 특징 | 추천 상황 |
|---|---|---|
| CMake | 크로스 플랫폼, 가장 널리 쓰임 | 대부분의 C 프로젝트 |
| Meson | 빠르고 현대적, Python 기반 | 새 프로젝트, 빠른 빌드 필요 |
| Bazel | Google 개발, 대규모 모노레포 | 대형 조직, 복잡한 의존성 |
| Make (개선) | 기존 Makefile 개선 | 마이그레이션 비용 최소화 |
리팩터링은 한 번 하고 끝나는 게 아니야. 지속적으로 코드 품질을 관리해야 해.
이게 바로 지속적 개선(Continuous Improvement)의 핵심이야.
팀 전체가 따르는 코딩 표준이 없으면 리팩터링해도 금방 다시 엉망이 돼.
C언어 프로젝트에서 많이 참고하는 코딩 표준들:
MISRA C — 자동차 산업 표준. 안전 필수 시스템에서 많이 씀. 매우 엄격.
CERT C Coding Standard — 보안 중심. SEI(Software Engineering Institute)에서 만든 거야.
Linux Kernel Coding Style — 오픈소스 프로젝트에서 많이 참고.
Google C++ Style Guide — C에도 적용 가능한 부분이 많아.
팀 자체 표준 만들 때 포함할 내용:
- 들여쓰기: 탭 vs 스페이스, 몇 칸
- 이름 규칙: snake_case vs camelCase
- 함수 최대 길이
- 주석 스타일 (Doxygen 형식 권장)
- 에러 처리 방식
- 메모리 관리 규칙
clang-format — 코드 스타일 자동 정렬. .clang-format 파일로 규칙 설정.# .clang-format 예시
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
AllowShortFunctionsOnASingleLine: None
BreakBeforeBraces: Linux
린터 (Linter)
clang-tidy — 정적 분석 + 자동 수정 제안. CI에 통합하기 좋아.# clang-tidy 실행 예시
clang-tidy myfile.c -- -I./include -std=c11
# 자동 수정
clang-tidy --fix myfile.c -- -I./include
Git hooks로 자동화
#!/bin/sh
# .git/hooks/pre-commit
# 커밋 전 자동 포맷 체크
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.c$\|\.h$')
if [ -n "$files" ]; then
clang-format --dry-run --Werror $files
if [ $? -ne 0 ]; then
echo "코드 포맷 오류! clang-format을 실행하세요."
exit 1
fi
fi
레거시 C 프로젝트에 CI/CD를 도입하면 코드 품질을 자동으로 관리할 수 있어.
GitHub Actions 예시:
# .github/workflows/ci.yml
name: C 코드 품질 검사
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 빌드
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
- name: 단위 테스트 실행
run: cmake --build build --target test
- name: 정적 분석 (cppcheck)
run: |
cppcheck --error-exitcode=1 \
--enable=all \
--suppress=missingIncludeSystem \
src/
- name: 코드 커버리지
run: |
cmake -B build-cov \
-DCMAKE_BUILD_TYPE=Debug \
-DCOVERAGE=ON
cmake --build build-cov
cd build-cov && ctest
lcov --capture --directory . --output-file coverage.info
lcov --remove coverage.info '/usr/*' --output-file coverage.info
리팩터링하다 보면 "이건 나중에 고쳐야지" 하는 부분들이 생겨. 이걸 그냥 두면 기술 부채가 쌓여.
체계적으로 관리하는 방법:
/* TODO(담당자, 날짜): 설명
* 예: TODO(kim, 2024-01): 이 함수를 두 개로 분리해야 함
*/
/* FIXME(담당자): 알려진 버그
* 예: FIXME(lee): 멀티스레드 환경에서 race condition 가능성
*/
/* HACK: 임시 해결책 - 나중에 제대로 고쳐야 함
* 예: HACK: 메모리 정렬 문제로 인한 임시 패딩
*/
/* DEPRECATED: 더 이상 사용하지 말 것
* 대신 new_function()을 사용하세요
*/
이런 주석들을 정기적으로 검토하고 이슈 트래커(Jira, GitHub Issues 등)에 등록해서 관리해야 해.
C언어를 쓰는 이유 중 하나가 성능이잖아. 리팩터링하면서 성능이 떨어지면 안 되지.
근데 사실 잘 짜인 코드가 성능도 더 좋은 경우가 많아. 컴파일러 최적화가 더 잘 되거든.
성능 최적화는 반드시 프로파일링 결과를 보고 해야 해. 추측으로 최적화하면 안 돼.
"섣부른 최적화는 모든 악의 근원이다" — 도널드 크누스(Donald Knuth)
gprof — GCC 내장. 함수별 실행 시간 측정.
gcc -pg -o myapp myapp.c
./myapp
gprof myapp gmon.out | head -30
Valgrind (callgrind) — 더 정밀한 프로파일링.
valgrind --tool=callgrind ./myapp
callgrind_annotate callgrind.out.PID
perf — Linux 커널 레벨 프로파일링.
perf record ./myapp
perf report
#include <time.h>
#include <stdio.h>
#define BENCHMARK(name, iterations, code) do { \
clock_t start = clock(); \
for (int _i = 0; _i < (iterations); _i++) { \
code; \
} \
clock_t end = clock(); \
double elapsed = (double)(end - start) / CLOCKS_PER_SEC; \
printf("[%s] %d회 실행: %.4f초 (평균: %.2f ns)\n", \
name, iterations, elapsed, \
elapsed * 1e9 / (iterations)); \
} while(0)
/* 사용 예 */
BENCHMARK("old_function", 1000000, old_function(data));
BENCHMARK("new_function", 1000000, new_function(data));
리팩터링된 코드는 컴파일러가 더 잘 최적화할 수 있어. 몇 가지 힌트를 주는 방법들:
/* restrict 키워드 - 포인터 앨리어싱 없음을 컴파일러에 알림 */
void vector_add(float * restrict dst,
const float * restrict a,
const float * restrict b,
int n) {
for (int i = 0; i < n; i++) {
dst[i] = a[i] + b[i];
}
}
/* inline 힌트 */
static inline int max(int a, int b) {
return a > b ? a : b;
}
/* likely/unlikely 힌트 (GCC/Clang 확장) */
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (unlikely(ptr == NULL)) {
/* 에러 처리 - 거의 발생 안 하는 경우 */
return -1;
}
이론은 충분히 봤으니까, 실제 시나리오를 보자. 임베디드 시스템의 센서 데이터 처리 모듈을 현대화하는 예시야.
/* sensor.c - 레거시 버전 (실제로 이런 코드 많음 ㅋㅋ) */
#include <stdio.h>
#include <string.h>
int s = 0; /* 센서 상태 */
float d[100]; /* 데이터 버퍼 */
int dc = 0; /* 데이터 카운트 */
int e = 0; /* 에러 코드 */
char msg[256]; /* 메시지 버퍼 */
void init() {
s = 0;
dc = 0;
e = 0;
memset(d, 0, sizeof(d));
}
int rd(int id) {
if (s == 0) {
e = 1;
return -1;
}
/* 하드웨어 레지스터 직접 접근 */
float v = *((volatile float*)0x40001000 + id);
if (v < -100.0 || v > 100.0) {
e = 2;
sprintf(msg, "err: val=%f", v);
return -1;
}
if (dc < 100) {
d[dc++] = v;
}
return 0;
}
float avg() {
if (dc == 0) return 0.0;
float sum = 0;
int i;
for (i = 0; i < dc; i++) sum += d[i];
return sum / dc;
}
이거 보면 뭐가 문제인지 보이지? 😅전역 변수 남발, 의미없는 이름들, 하드코딩된 주소, 테스트 불가능한 구조...
/* sensor.h - 현대화된 버전 */
#ifndef SENSOR_H
#define SENSOR_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
/* 상수 정의 */
#define SENSOR_MAX_SAMPLES 100
#define SENSOR_VALUE_MIN -100.0f
#define SENSOR_VALUE_MAX 100.0f
/* 에러 코드 */
typedef enum {
SENSOR_OK = 0,
SENSOR_ERR_NOT_INIT = -1,
SENSOR_ERR_INVALID_VAL = -2,
SENSOR_ERR_BUFFER_FULL = -3,
SENSOR_ERR_NO_DATA = -4
} SensorError;
/* 하드웨어 추상화 - 테스트 가능하게 */
typedef float (*sensor_read_hw_fn)(int sensor_id);
/* 불투명 핸들 */
typedef struct SensorContext SensorContext;
/* API */
SensorContext* sensor_create(sensor_read_hw_fn read_fn);
void sensor_destroy(SensorContext *ctx);
SensorError sensor_read(SensorContext *ctx, int sensor_id);
SensorError sensor_get_average(const SensorContext *ctx, float *out_avg);
const char* sensor_error_string(SensorError err);
#endif /* SENSOR_H */
/* sensor.c - 현대화된 구현 */
#include "sensor.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
struct SensorContext {
sensor_read_hw_fn read_hw;
float samples[SENSOR_MAX_SAMPLES];
int sample_count;
bool is_initialized;
};
SensorContext* sensor_create(sensor_read_hw_fn read_fn) {
if (read_fn == NULL) return NULL;
SensorContext *ctx = calloc(1, sizeof(SensorContext));
if (ctx == NULL) return NULL;
ctx->read_hw = read_fn;
ctx->is_initialized = true;
return ctx;
}
void sensor_destroy(SensorContext *ctx) {
if (ctx != NULL) {
memset(ctx, 0, sizeof(*ctx)); /* 민감한 데이터 지우기 */
free(ctx);
}
}
static bool is_value_valid(float value) {
return (value >= SENSOR_VALUE_MIN && value <= SENSOR_VALUE_MAX);
}
SensorError sensor_read(SensorContext *ctx, int sensor_id) {
if (ctx == NULL || !ctx->is_initialized) return SENSOR_ERR_NOT_INIT;
if (ctx->sample_count >= SENSOR_MAX_SAMPLES) return SENSOR_ERR_BUFFER_FULL;
float value = ctx->read_hw(sensor_id);
if (!is_value_valid(value)) return SENSOR_ERR_INVALID_VAL;
ctx->samples[ctx->sample_count++] = value;
return SENSOR_OK;
}
SensorError sensor_get_average(const SensorContext *ctx, float *out_avg) {
if (ctx == NULL || out_avg == NULL) return SENSOR_ERR_NOT_INIT;
if (ctx->sample_count == 0) return SENSOR_ERR_NO_DATA;
float sum = 0.0f;
for (int i = 0; i < ctx->sample_count; i++) {
sum += ctx->samples[i];
}
*out_avg = sum / (float)ctx->sample_count;
return SENSOR_OK;
}
const char* sensor_error_string(SensorError err) {
switch (err) {
case SENSOR_OK: return "성공";
case SENSOR_ERR_NOT_INIT: return "초기화되지 않음";
case SENSOR_ERR_INVALID_VAL: return "유효하지 않은 값";
case SENSOR_ERR_BUFFER_FULL: return "버퍼 가득 참";
case SENSOR_ERR_NO_DATA: return "데이터 없음";
default: return "알 수 없는 오류";
}
}
/* test_sensor.c - 테스트 코드 */
#include "unity.h"
#include "sensor.h"
/* 테스트용 하드웨어 시뮬레이터 */
static float mock_hw_value = 25.0f;
static float mock_read_hw(int sensor_id) {
(void)sensor_id; /* 미사용 매개변수 경고 억제 */
return mock_hw_value;
}
static SensorContext *ctx;
void setUp(void) {
ctx = sensor_create(mock_read_hw);
}
void tearDown(void) {
sensor_destroy(ctx);
ctx = NULL;
}
void test_sensor_create_success(void) {
TEST_ASSERT_NOT_NULL(ctx);
}
void test_sensor_read_normal_value(void) {
mock_hw_value = 25.0f;
SensorError err = sensor_read(ctx, 0);
TEST_ASSERT_EQUAL_INT(SENSOR_OK, err);
}
void test_sensor_read_invalid_value(void) {
mock_hw_value = 999.0f; /* 범위 초과 */
SensorError err = sensor_read(ctx, 0);
TEST_ASSERT_EQUAL_INT(SENSOR_ERR_INVALID_VAL, err);
}
void test_sensor_get_average(void) {
mock_hw_value = 10.0f;
sensor_read(ctx, 0);
mock_hw_value = 20.0f;
sensor_read(ctx, 0);
mock_hw_value = 30.0f;
sensor_read(ctx, 0);
float avg;
SensorError err = sensor_get_average(ctx, &avg);
TEST_ASSERT_EQUAL_INT(SENSOR_OK, err);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 20.0f, avg);
}
이제 하드웨어 없이도 테스트할 수 있어! 🎉mock_read_hw 함수로 하드웨어를 시뮬레이션하는 거야.
경험자들이 레거시 C 코드 리팩터링하면서 자주 하는 실수들을 정리해봤어.
이거 알고 시작하면 훨씬 덜 고생해 ㅋㅋ
"이왕 하는 거 다 고쳐버리자!" → 어디서 버그 났는지 모름 → 롤백도 못 함
✅ 해결: 작은 단위로 변경하고, 각 변경마다 테스트 통과 확인. 커밋도 자주.
"이 코드 이해했으니까 그냥 고쳐도 돼" → 엣지 케이스 놓침 → 프로덕션 장애
✅ 해결: 리팩터링 전에 반드시 테스트 먼저. 테스트가 없으면 테스트 추가가 첫 번째 작업.
"이 코드 왜 이렇게 복잡하게 짰지? 단순하게 바꾸자" → 성능 10배 저하
레거시 코드의 이상한 구조에는 이유가 있을 수 있어. 특히 임베디드나 실시간 시스템에서.
✅ 해결: 변경 전후 벤치마크 필수. 이상한 코드는 주석이나 문서 찾아보기.
함수 시그니처 바꿨더니 다른 팀 코드가 다 깨짐
✅ 해결: 공개 API는 신중하게. 필요하면 deprecated 래퍼 함수 제공하고 점진적으로 마이그레이션.
임베디드 시스템에서 C99 VLA 썼다가 스택 오버플로우
✅ 해결: 타겟 플랫폼의 제약 조건 먼저 파악. 메모리, 스택 크기, 지원 표준 확인.
"리팩터링하면서 이 기능도 추가하자" → 버그 원인 파악 불가
✅ 해결: 리팩터링 커밋과 기능 추가 커밋을 분리. 절대 섞지 마.
/* 이거 왜 있는지 모르겠음 */ 하고 지웠다가 나중에 알고 보니 특정 하드웨어 버그 워크어라운드였던 경우가 실제로 많아. git blame, git log, 주석, 문서를 최대한 찾아보고 이해한 다음에 제거해.
레거시 코드를 보다 보면 "그냥 처음부터 다시 짜는 게 낫지 않나?" 하는 생각이 들 때가 있어.
이게 진짜 어려운 결정이야. 조엘 스폴스키(Joel Spolsky)가 "Things You Should Never Do"라는 글에서 이걸 경고했는데 — 처음부터 다시 짜는 건 대부분 실패한다고.
| 상황 | 리팩터링 | 재작성 |
|---|---|---|
| 코드 크기 | 대부분의 경우 | 매우 작은 모듈 |
| 비즈니스 로직 | 복잡한 도메인 지식 포함 | 단순하고 명확한 경우 |
| 요구사항 변화 | 기존 요구사항 유지 | 완전히 새로운 요구사항 |
| 기술 부채 | 관리 가능한 수준 | 감당 불가능한 수준 |
| 팀 역량 | 기존 코드 이해 가능 | 완전히 이해 불가 |
마틴 파울러(Martin Fowler)가 제안한 패턴이야. 레거시 시스템을 한 번에 교체하지 않고, 새 시스템이 레거시를 점진적으로 "감싸면서" 대체하는 방식이야.
1단계: 레거시 모듈 주변에 새 인터페이스 래퍼 작성
/* 레거시 함수 */
int old_process(char *buf, int len) { /* ... */ }
/* 새 래퍼 - 점진적으로 내부를 교체 */
ProcessResult new_process(const ProcessInput *input) {
/* 처음에는 레거시 호출 */
char buf[1024];
int result = old_process(buf, sizeof(buf));
/* 나중에 이 부분을 새 구현으로 교체 */
return convert_result(result, buf);
}
2단계: 모든 호출자가 새 인터페이스 사용하도록 마이그레이션
3단계: 레거시 함수 내부를 새 구현으로 교체
4단계: 래퍼 제거 (선택적)
지금까지 엄청 많은 내용을 다뤘는데, 핵심만 정리해볼게. 🎯
📋 분석 의존성 맵 그렸나?
📋 분석 핵심 기능 목록 작성했나?
🧪 테스트 핵심 기능에 테스트 추가했나?
🧪 테스트 커버리지 측정했나?
🧪 테스트 성능 벤치마크 기준선 잡았나?
📝 계획 리팩터링 우선순위 정했나?
📝 계획 팀과 계획 공유했나?
🔧 환경 버전 관리(git) 설정됐나?
🔧 환경 CI 파이프라인 있나?
솔직히 말하면, 레거시 C 코드를 완벽하게 현대화하는 건 불가능에 가까워.
시스템은 계속 돌아가야 하고, 새 기능 요청은 계속 들어오고, 리팩터링할 시간은 항상 부족하거든.
근데 그게 포기할 이유는 아니야. 조금씩, 꾸준히가 핵심이야.
보이스카우트 규칙처럼 — "캠프장을 처음 왔을 때보다 더 깨끗하게 두고 떠나라."
코드도 마찬가지야. 오늘 건드린 코드를 어제보다 조금 더 나은 상태로 두고 가면 돼.
C언어 레거시 코드는 세상의 핵심 인프라를 돌리고 있어. 이걸 잘 관리하고 현대화하는 능력은 정말 귀한 기술이야.
어렵지만, 그만큼 가치 있는 일이야. 화이팅! 💪
📖 Working Effectively with Legacy Code — Michael Feathers
📖 Refactoring: Improving the Design of Existing Code — Martin Fowler
📖 Clean Code — Robert C. Martin
📖 The Art of Readable Code — Dustin Boswell & Trevor Foucher
🌐 CERT C Coding Standard — sei.cmu.edu
🌐 cppcheck 공식 문서 — cppcheck.sourceforge.io
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.