추상화와 코드 품질
HLS를 위한 C++ 코드는 하드웨어를 설명하는 코드이면서 동시에 소프트웨어 코드입니다. 따라서 소프트웨어 개발에서 중요하게 여겨지는 읽기 쉽고 유지 보수가 쉬운 코드 작성은 HLS를 활용하는 하드웨어 엔지니어에게도 중요합니다.
하드웨어 엔지니어에게 읽기 쉽고 유지 보수가 쉬운 코드를 작성한다는 개념은 낯설게 여겨질 수도 있습니다. 그러나 HLS가 RTL 설계보다 압도적인 생산성을 보여주려면 하드웨어 프로젝트를 이러한 소프트웨어 관점에서도 바라볼 수 있어야 합니다.
제가 생각했을 때 좋은 코드를 쓴다는 것은 결국 복잡한 것을 올바르게 추상화하여 쉽게 설명하는 것인데요, 이번 글에서는 추상화와 코드 품질이 어떻게 연결되는지 설명드리겠습니다.
추상화
추상화는 복잡한 것을 만들 때 꼭 필요한 기법입니다. 추상화가 무엇인지 이야기하기 전에 김춘수의 시 <꽃>을 읽어볼까요?꽃>
내가 그의 이름을 불러주기 전에는
그는 다만
하나의 몸짓에 지나지 않았다.
내가 그의 이름을 불러주었을 때,
그는 나에게로 와서
꽃이 되었다.
우리가 꽃가루를 생성하는 수술과 꽃가루를 받는 암술, 그리고 꽃잎, 꽃받침 등의 기관으로 이루어진 것을 “꽃”이라고 부르는 순간, 우리는 이것을 꽃으로 추상화 한 겁니다.
어떤 물체를 유리, 나무, 못의 결합이라고 하지 않고 “집”이라고 부른다면 추상화를 한 것이다. 집이 모여있는 집합을 “마을”이라고 한다면 그 또한 추상화한 것이다. - Steven C. McConnell. (2004). Code Complete, 2nd Edition. Microsoft Press.
프로그래밍에서는 변수, 함수, 구조체, 클래스를 사용해서 추상화를 할 수 있죠.
변수를 예를 들면,
const int legalAge = 19;
컴퓨터 메모리에는 19
이라는 구체적인 데이터가 저장되겠지만 legalAge
는 성인이 되는 나이를 나타내는 추상화된 이름입니다.
함수로 예를 들면,
#include <iostream>
// 기본 세금과 누진세율을 적용하여 세금을 계산하는 함수
double calculateTax(double amount) {
double tax = 0.0;
if (amount <= 10000) tax = amount * 0.05; // 5% 세율
else if (amount <= 50000) tax = 10000 * 0.05 + (amount - 10000) * 0.1; // 10% 세율
else tax = 10000 * 0.05 + 40000 * 0.1 + (amount - 50000) * 0.2; // 20% 세율
return tax;
}
int main() {
double amount = 60000.0;
double tax = calculateTax(amount);
std::cout << "Total tax: " << tax << std::endl;
return 0;
}
기본 세금과 누진세율을 적용하여 세금을 계산하는 일련의 과정들을 묶어서 calculateTax라고 이름 붙여 추상화했습니다.
왜 추상화가 필요할까?
복잡한 것을 이해하기 쉽도록 단순하게 만들기 위함입니다. 수렵 채집 생활을 하던 인간과 요즘 첨단 기술을 개발하는 인간의 뇌는 많이 다르지 않습니다. 그러나 예전에 비하면 인간이 다뤄야 할 문제의 복잡성이 엄청나게 높아졌습니다. 인간이 한 번에 이해할 수 있는 것에는 한계가 있으므로, 각 요소들을 추상화함으로써 한 번에 한 부분을 제대로 집중할 수 있게 만들어야 합니다. 어려운 문제를 쉽게 해결하기 위해서 추상화를 사용합니다.
자동차를 예를 들어 볼까요? 자동차는 추상화가 잘 되어있기 때문에 운전을 배울 때 큰 어려움을 겪지 않습니다. 핸들, 브레이크, 악셀 등의 인터페이스만 신경 쓰면 됩니다. 운전자는 신경 쓰지 않아도 될 요소들, 즉 엔진 내부에서 연료가 어떻게 연소되고 동력이 발생하는지, 각 실린더가 어떻게 작동하는지, ABS(브레이크 잠김 방지 시스템)가 어떻게 동작하는지 등 복잡한 메커니즘을 알 필요가 없습니다. 운전하려면 악셀을 밟으면 연료가 얼마큼 엔진으로 들어가서 연소되는지까지 이해해야 한다면 운전이 훨씬 더 어렵습니다.
우리가 접하는 대부분의 것들은 이렇게 추상화가 잘 되어있습니다. 추상화를 하면 구체적으로 어떤 이점이 있을까요?
코드 복잡성 감소
추상화를 하면 코드를 이해하기 쉽습니다. 추상화를 하지 않고 모든 세부사항을 한 코드에 몰아넣으면 코드를 이해하기 어렵습니다.
processFile
이라는 함수를 예로 들어보겠습니다.
[코드 1 - 모든 구현을 하나의 함수로 만든 코드]
void processFile(std::ifstream& file) {
std::string line;
while (std::getline(file, line)) {
if (line.empty()) {
if (line.find("ERROR") != std::string::npos) {
if (line.find("CRITICAL") != std::string::npos) {
std::cout << "Critical error: " << line << std::endl;
} else {
std::cout << "Error: " << line << std::endl;
}
} else if (line.find("WARNING") != std::string::npos) {
std::cout << "Warning: " << line << std::endl;
} else {
std::cout << "Info: " << line << std::endl;
}
}
}
}
[코드 2 - 세부 구현을 함수로 추상화한 코드]
void processFile(std::ifstream& file) {
std::string line;
while (std::getline(file, line)) {
if (!line.empty()) {
printLogType(line);
}
}
}
각 줄을 어떻게 처리해야 하는지 세부 구현 사항까지 알아야 할 필요가 있으면 printLogType
함수를 참고하면 되고, 그럴 필요가 없으면 그냥 넘어가면 됩니다.
void printLogType(const std::string& line) {
if (line.find("ERROR") != std::string::npos) {
if (line.find("CRITICAL") != std::string::npos) {
std::cout << "Critical error: " << line << std::endl;
} else {
std::cout << "Error: " << line << std::endl;
}
} else if (line.find("WARNING") != std::string::npos) {
std::cout << "Warning: " << line << std::endl;
} else {
std::cout << "Info: " << line << std::endl;
}
}
두 코드의 복잡성 차이를 잘 모르겠다, 나는 [코드 1]이 더 마음에 든다고 생각하는 분도 있을 거라 생각합니다. 물론 어디까지를 함수(또는 구조체, 클래스)로 쪼개서 추상화해야 할지는 정답이 없는 문제입니다. 함수를 얼마나 작게 만들지에 대해서는 아래에서 더 자세히 다루겠습니다.
쉬운 유지보수
추상화가 제대로 되어있지 않은 코드는 기능 변경 등의 수정이 어렵습니다. 한 번에 하나를 집중해서 작업할 수 없기 때문입니다. 한 부분이 다른 부분들과 불필요하게 엮여있는 경우 하나를 고치기 위해서는 다른 모든 부분도 같이 고려해서 고쳐야 합니다. 추상화를 하지 않고 엄청나게 큰 무언가를 만들어놓으면 추상화도 제대로 되어있지 않고 꼬여있어서 수정하기가 어려울 가능성이 큽니다.
아래는 추상화가 제대로 되어있지 않은 코드의 예시입니다.
// 설정 구조체
struct Settings {
int value1;
// ...
// ...
};
// 전등 클래스
class Light {
public:
Settings* settings;
Light(Settings* settings) : settings(settings) {}
void operate() {
cout << "Light brightness: " << settings->value1 << "%" << endl; // value1을 밝기로 사용
}
};
// 에어컨 클래스
class AirConditioner {
public:
Settings* settings;
AirConditioner(Settings* settings) : settings(settings) {}
void operate() {
cout << "AirConditioner temperature: " << settings->value1 << "°C" << endl; // value1을 온도로 사용
}
};
// TV 클래스
class Television {
public:
Settings* settings;
Television(Settings* settings) : settings(settings) {}
void operate() {
cout << "Television channel number: " << settings->value1 << endl; // value1을 채널 번호로 사용
}
};
만약 이 상황에서 TV가 채널 번호가 아니라 채널 이름을 사용하도록 요구사항이 변경되어 Settings
구조체의 value1
을 문자열로 바꿔야 한다면 어떻게 해야 할까요? 전등과 에어컨도 문자열을 처리하도록 수정해야 합니다. 뭔가 이상하죠. 이상한 이유는 추상화가 잘못되어있기 때문입니다. 상식적으로 생각해 보면 TV의 설정값은 TV에 속해있고, 전등의 설정값과 에어컨의 설정값도 각각 전등과 에어컨에 속해있습니다. TV와 전등, 에어컨이 같은 설정값 구조체를 사용한다는 것은 이상합니다. 이상한 걸 코드로 구현해놨으니 이해할 수 없는 문제들이 생깁니다.
추상화를 올바르게 해놨다면 TV의 기능 변경에 대해서는 TV만 고치면 됩니다. 아래는 올바르게 추상화된 코드입니다.
// 전등 설정 구조체
struct LightSettings {
int brightness; // 밝기
};
// 에어컨 설정 구조체
struct AirConditionerSettings {
int temperature; // 온도
};
// TV 설정 구조체
struct TelevisionSettings {
string channelName; // 채널 이름
};
// 전등 클래스
class Light {
public:
LightSettings settings;
Light(int brightness) {
settings.brightness = brightness;
}
void operate() {
cout << "Light brightness: " << settings.brightness << "%" << endl;
}
};
// 에어컨 클래스
class AirConditioner {
public:
AirConditionerSettings settings;
AirConditioner(int temperature) {
settings.temperature = temperature;
}
void operate() {
cout << "AirConditioner temperature: " << settings.temperature << "°C" << endl;
}
};
// TV 클래스
class Television {
public:
TelevisionSettings settings;
Television(string channelName) {
settings.channelName = channelName;
}
void operate() {
cout << "Television channel name: " << settings.channelName << endl;
}
};
함수는 얼마나 작게 만들어야 할까?
회사나 팀에서 일하시는 분은 ‘나’를 기준으로 하는 것보다 평범한 대다수의 사람들의 기준으로 코드를 작성해야 합니다. 다른 사람들이 이해하기 쉽고, 유지 보수하기 쉬운 코드를 작성해야 합니다. 내가 보기에 이 정도가 괜찮다는 바람직하지 않습니다. 여러분이 매우 똑똑하다면 여러분에게는 쉬운 코드가 같이 일하는 다른 사람들에게는 어려울 수 있으니까요. 따라서 대부분의 사람들이 이해하기 쉬운 코드의 기준이 필요합니다. 아래에서 이를 위한 몇 가지 기준을 말씀드리겠습니다.
Congnitive Complexity
정적 분석 툴 SonarLint를 만든 회사 SonarSource에서는 함수의 Cognitive Complexity를 3 이하로 만들기를 권하고 있습니다.
Cogtivie Complexity는 간단히 말하자면 코드를 읽는 흐름을 방해하는 복잡성을 나타내는 지표입니다. Cognitive Complextity가 높을수록 이해하기가 어렵습니다.
void myMethod () {
try {
if (condition1) { // +1
for (int i = 0; i < 10; i++) { // +2 (nesting=1)
while (condition2) { … } // +3 (nesting=2)
}
}
} catch (ExcepType1 | ExcepType2 e) { // +1
if (condition2) { … } // +2 (nesting=1)
}
} // Cognitive Complexity 9
자세한 계산 방식은 SonarSouce에서 확인하거나 SonarLint와 같은 정적 분석 도구를 이용해 확인할 수 있습니다.
Clean Code
클린 코드 책에서는 함수를 작게 만들 것을 강조합니다. 중첩 구조가 생길 만큼 함수가 커져서는 안되고 함수에서 들여쓰기 수준은 1단이나 2단을 넘어서면 안 된다고 말합니다. 예를 들어, if/else문 안에서 또다시 if/else문이 들어가는 경우가 없어야 한다는 것이죠. Cognitive Complexity 개념과 비슷합니다.
함수를 만드는 첫째 규칙은 ‘작게!’다. 함수를 만드는 둘째 규칙은 ‘더 작게!’다. - Robert C. Martin. (2013). Clean Code(클린 코드). 인사이트.
ChatGPT
ChatGPT에게 함수는 몇 줄 정도가 적절한지를 물어봤습니다. 일반적으로 20~30줄 이내가 적당하다고 말합니다.
Q. 프로그래밍 할 때 함수의 줄 수는 몇 줄이 적당해?
함수의 적절한 줄 수는 맥락과 목적에 따라 달라질 수 있지만, 다음과 같은 일반적인 권장 사항이 있습니다:
1. 함수의 권장 길이
짧고 간결하게: 일반적으로 20~30줄 이내가 적당합니다.
단일 책임 원칙(Single Responsibility Principle): 함수는 하나의 작업만 수행하는 것이 좋습니다.
가독성과 유지 보수성: 함수가 너무 길어지면 읽기 어렵고 유지 보수도 힘들어질 수 있습니다.
2. 예외 상황
복잡한 알고리즘: 복잡한 로직을 처리하는 함수는 길어질 수 있습니다.
스크립트나 프로토타입 코드: 빠르게 개발해야 하는 경우 더 길어질 수도 있습니다.
3. 분할 기준
반복되는 코드: 반복적인 코드는 별도의 함수로 분리하는 것이 좋습니다.
논리적 단계 분리: 함수가 여러 단계의 작업을 수행한다면 각 단계를 별도 함수로 나누는 것을 고려해 보세요.
결론
권장 길이: 20~30줄 이내가 이상적입니다.
최대 허용 길이: 프로젝트의 코드 표준에 따라 50~100줄까지 허용하는 경우도 있습니다.
함수 길이를 줄이기 어려운 경우에는 설명 주석과 명확한 변수/함수명을 사용하여 가독성을 높이는 것도 좋은 방법입니다.
My Opinion
Master programmers think of systems as stories to be told rather than programs to be written. — Robert C Martin
대가(Master) 프로그래머는 시스템을 (구현할) 프로그램이 아니라 (풀어갈) 이야기로 여긴다.
제가 추천하고 싶은 것은 “말로 설명하는 대로 코드를 써라”입니다.
위에서 processFile
이라는 함수로 예를 들었었습니다. 만일 여러분에게 processFile
은 무엇을 하는 함수인가요?라고 물어봤을 때 여러 가지 방식으로 대답할 수 있을 텐데요,
- A: 파일을 열 수 없을 경우 오류 메시지를 출력하고 종료합니다. 그렇지 않으면 파일의 내용을 한 줄씩 읽으면서 빈 줄이 아니면 각 줄의 로그 타입을 출력합니다.
- B: 파일을 열 수 없을 경우 오류 메시지를 출력하고 종료합니다. 그렇지 않으면 파일의 내용을 한 줄씩 읽습니다. 읽은 줄이 비어있으면 건너뜁니다. 만약 읽은 줄에
ERROR
문자열이 있으면,CRITICAL
문자열도 포함되어 있을 경우 “Critical Error: “와 해당 줄을 출력하고, 그렇지 않은 경우 “Error “와 해당 줄을 출력합니다. 그렇지 않고 읽은 줄에WARNING
문자열이 포함되어 있을 경우 “Warning: “ 메시지와 해당 줄을 출력합니다. 나머지 경우에는 “Info: “ 문자열과 함께 해당 줄을 출력합니다.
A와 같이 설명하는 분들은 [코드 2] 방식으로 코드를 작성하면 되고요, B와 같이 설명하는 분들은 [코드 1]과 같이 작성하면 되겠습니다.
프로그래밍 언어에서 제공하는 변수, 함수, 구조체, 클래스 등의 추상화 도구들을 충분히 사용해서 말하는 방식대로 코딩하면 좋을듯합니다.
마무리
결국 변수, 구조체, 함수, 클래스를 잘 사용하는 것이 추상화입니다. 추상화를 잘 했다는 것은, 의미를 잘 나타내는 변수 이름을 짓고, 동작을 쉽게 이해할 수 있도록 함수를 쪼개고, 서로 관련 있는 데이터들을 묶어서 구조체로 만들고, 관련 있는 데이터와 동작들을 묶어 클래스로 표현하는 것이죠. 다만 내가 설명할 것을 어떻게 추상화하는 게 좋은지에 대한 방법은 정답이 없기 때문에 어려운 일인 것 같습니다.
좋은 코드를 쓰는 방법에 대해서는 서점에 많은 책들이 있습니다. Clean Code나 Code Quality 키워드로 검색해 보면 나옵니다. 시간 되면 꼭 읽어보시길 추천드립니다.
Leave a comment