- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 카테고리 1: 네이밍 (Naming) — 6가지 규칙
- 카테고리 2: 함수 (Functions) — 6가지 규칙
- 카테고리 3: 주석 (Comments) — 4가지 규칙
- 카테고리 4: 에러 처리 (Error Handling) — 4가지 규칙
- 카테고리 5: 코드 구조화 (Code Organization) — 5가지 규칙
- 카테고리 6: 테스트 (Testing) — 5가지 규칙
- 코드 냄새 카탈로그 (Code Smells)
- 리팩토링 기법 Before/After
- 인지 복잡도 (Cognitive Complexity)
- 코드 리뷰 체크리스트
- SOLID 원칙 빠른 참조
- 실전 퀴즈
- 도구와 자동화
- 참고 자료
들어가며
"동작하는 코드"와 "읽기 좋은 코드" 사이에는 넘을 수 없을 것 같은 간극이 있습니다. 대부분의 개발자는 기능 구현에 집중하느라 코드 품질을 후순위로 미루고, 결국 유지보수 비용이 눈덩이처럼 불어나는 경험을 합니다.
Robert C. Martin(Uncle Bob)의 "Clean Code"가 출판된 지 17년이 지났지만, 그 원칙들은 여전히 유효합니다. 다만 TypeScript, Python, Rust 같은 현대 언어와 도구들이 등장하면서 실천 방법이 진화했습니다.
이 글에서는 30가지 실전 규칙을 6개 카테고리로 나누어, 각각 Bad/Good 코드 비교와 함께 설명합니다. 모든 예제는 TypeScript와 Python으로 제공됩니다.
보이스카우트 규칙(Boy Scout Rule): 코드를 발견했을 때보다 더 깨끗한 상태로 남겨라.
카테고리 1: 네이밍 (Naming) — 6가지 규칙
규칙 1: 의도를 드러내는 이름을 사용하라
변수, 함수, 클래스의 이름만으로 "무엇을 하는지", "왜 존재하는지"를 알 수 있어야 합니다.
Bad:
// 무엇을 의미하는지 전혀 알 수 없다
const d: number = 86400;
const list1: number[] = [1, 2, 3];
function calc(a: number, b: number): number {
return a * b * 0.1;
}
Good:
// 이름만 봐도 의미가 명확하다
const SECONDS_PER_DAY: number = 86400;
const activeUserIds: number[] = [1, 2, 3];
function calculateDiscountedPrice(originalPrice: number, quantity: number): number {
const DISCOUNT_RATE = 0.1;
return originalPrice * quantity * DISCOUNT_RATE;
}
Python 버전:
# Bad
d = 86400
lst = [1, 2, 3]
def calc(a, b):
return a * b * 0.1
# Good
SECONDS_PER_DAY = 86400
active_user_ids = [1, 2, 3]
def calculate_discounted_price(original_price: float, quantity: int) -> float:
DISCOUNT_RATE = 0.1
return original_price * quantity * DISCOUNT_RATE
규칙 2: 축약어를 피하라
축약어는 작성자에게만 명확하고, 다른 개발자에게는 수수께끼입니다.
Bad:
const usrMgr = new UserManager();
const btnClkHndlr = () => {};
const genRpt = (dt: Date) => {};
const tmpUsr = getUser();
Good:
const userManager = new UserManager();
const handleButtonClick = () => {};
const generateReport = (reportDate: Date) => {};
const temporaryUser = getUser(); // 맥락상 임시 변수라면 이조차도 재고
허용되는 축약어: 업계 표준인 경우에만 — id, url, api, db, io, html, css, http
규칙 3: 검색 가능한 이름을 사용하라
매직 넘버나 한 글자 변수는 프로젝트 전체에서 검색이 불가능합니다.
Bad:
// 5가 무엇을 의미하는가? 프로젝트에서 '5'를 검색하면 수백 개 결과가 나온다
if (user.role === 5) {
setTimeout(callback, 86400000);
}
Good:
const ADMIN_ROLE_ID = 5;
const ONE_DAY_IN_MILLISECONDS = 86400000;
if (user.role === ADMIN_ROLE_ID) {
setTimeout(callback, ONE_DAY_IN_MILLISECONDS);
}
규칙 4: 발음 가능한 이름을 사용하라
코드 리뷰에서 이름을 소리 내어 읽을 수 있어야 합니다.
Bad:
class DtaRcrd102 {
private genymdhms: Date; // "젠와이엠디에이치엠에스"??
private modymdhms: Date;
private pszqint: number;
}
Good:
class Customer {
private generationTimestamp: Date;
private modificationTimestamp: Date;
private recordId: number;
}
규칙 5: 일관된 어휘를 사용하라
동일한 개념에 여러 단어를 혼용하지 마세요.
Bad:
// 같은 개념에 fetch, retrieve, get, obtain을 혼용
class UserService {
fetchUser(id: number) {}
retrieveAccount(id: number) {}
getUserProfile(id: number) {}
obtainUserSettings(id: number) {}
}
Good:
// get으로 통일
class UserService {
getUser(id: number) {}
getAccount(id: number) {}
getUserProfile(id: number) {}
getUserSettings(id: number) {}
}
팀 네이밍 컨벤션 예시:
| 동작 | 규칙 | 예시 |
|---|---|---|
| 조회 (단건) | get + 명사 | getUser, getOrder |
| 조회 (목록) | list + 명사(복수) | listUsers, listOrders |
| 생성 | create + 명사 | createUser, createOrder |
| 수정 | update + 명사 | updateUser, updateOrder |
| 삭제 | delete + 명사 | deleteUser, deleteOrder |
| 존재 확인 | has/is + 형용사/명사 | hasPermission, isActive |
| 변환 | to + 목적형태 | toJSON, toString |
규칙 6: 스코프 길이에 비례하는 이름 길이
짧은 스코프에서는 짧은 이름이, 넓은 스코프에서는 설명적인 이름이 적합합니다.
Bad:
// 루프 변수에 긴 이름
for (let arrayIndex = 0; arrayIndex < items.length; arrayIndex++) {
process(items[arrayIndex]);
}
// 전역 상수에 짧은 이름
const T = 3000; // 무슨 타임아웃?
Good:
// 루프 변수는 짧게
for (let i = 0; i < items.length; i++) {
process(items[i]);
}
// 전역/모듈 수준은 설명적으로
const API_REQUEST_TIMEOUT_MS = 3000;
카테고리 2: 함수 (Functions) — 6가지 규칙
규칙 7: 함수는 작게 만들어라
함수의 이상적인 길이는 20줄 이내입니다. 한 화면에 들어와야 합니다.
Bad:
function processOrder(order: Order): OrderResult {
// 검증 (20줄)
if (!order.items || order.items.length === 0) {
throw new Error('No items');
}
if (!order.customer) {
throw new Error('No customer');
}
// ... 더 많은 검증
// 가격 계산 (30줄)
let total = 0;
for (const item of order.items) {
total += item.price * item.quantity;
}
const tax = total * 0.1;
const shipping = total > 100 ? 0 : 10;
// ... 더 많은 계산
// 결제 처리 (20줄)
const paymentResult = chargeCard(order.customer.cardId, total + tax + shipping);
// ... 결제 로직
// 알림 전송 (15줄)
sendEmail(order.customer.email, 'Order confirmed');
// ... 알림 로직
return { orderId: generateId(), total, status: 'completed' };
}
Good:
function processOrder(order: Order): OrderResult {
validateOrder(order);
const pricing = calculatePricing(order.items);
const paymentResult = processPayment(order.customer, pricing.total);
notifyCustomer(order.customer, paymentResult);
return createOrderResult(paymentResult, pricing);
}
function validateOrder(order: Order): void {
if (!order.items?.length) throw new InvalidOrderError('No items');
if (!order.customer) throw new InvalidOrderError('No customer');
}
function calculatePricing(items: OrderItem[]): Pricing {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * TAX_RATE;
const shipping = subtotal > FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_FEE;
return { subtotal, tax, shipping, total: subtotal + tax + shipping };
}
규칙 8: 한 가지만 하라 (Single Responsibility)
함수는 한 가지 일만 해야 하고, 그 일을 잘해야 합니다.
Bad:
function emailClients(clients: Client[]): void {
for (const client of clients) {
const record = database.lookup(client.id);
if (record.isActive()) {
email(client.email, 'Welcome!');
}
}
}
Good:
function emailActiveClients(clients: Client[]): void {
const activeClients = getActiveClients(clients);
activeClients.forEach(client => sendWelcomeEmail(client));
}
function getActiveClients(clients: Client[]): Client[] {
return clients.filter(client => isClientActive(client.id));
}
function isClientActive(clientId: number): boolean {
const record = database.lookup(clientId);
return record.isActive();
}
function sendWelcomeEmail(client: Client): void {
email(client.email, 'Welcome!');
}
규칙 9: 매개변수는 적게 유지하라 (이상적으로 2개 이하)
매개변수가 3개 이상이면 객체로 묶어 전달하세요.
Bad:
function createUser(
name: string,
email: string,
age: number,
role: string,
department: string,
isActive: boolean
): User {
// ...
}
createUser('Alice', 'alice@example.com', 30, 'admin', 'engineering', true);
Good:
interface CreateUserParams {
name: string;
email: string;
age: number;
role: UserRole;
department: string;
isActive: boolean;
}
function createUser(params: CreateUserParams): User {
// ...
}
createUser({
name: 'Alice',
email: 'alice@example.com',
age: 30,
role: UserRole.ADMIN,
department: 'engineering',
isActive: true,
});
규칙 10: 사이드 이펙트를 피하라
함수가 예상 밖의 동작을 하면 안 됩니다.
Bad:
let currentUser: User | null = null;
function checkPassword(userName: string, password: string): boolean {
const user = database.findUser(userName);
if (user && user.password === hash(password)) {
// 사이드 이펙트! 비밀번호 확인 함수가 세션을 초기화한다
currentUser = user;
initializeSession(user);
return true;
}
return false;
}
Good:
function verifyPassword(userName: string, password: string): boolean {
const user = database.findUser(userName);
return user !== null && user.password === hash(password);
}
function login(userName: string, password: string): LoginResult {
if (!verifyPassword(userName, password)) {
return { success: false };
}
const user = database.findUser(userName);
const session = initializeSession(user);
return { success: true, user, session };
}
규칙 11: 명령-쿼리 분리 (Command-Query Separation)
함수는 "무언가를 수행"하거나 "무언가를 반환"해야지, 둘 다 하면 안 됩니다.
Bad:
// set이 성공 여부도 반환하고, 값도 설정한다
function setAndCheckAttribute(name: string, value: string): boolean {
const attribute = findAttribute(name);
if (attribute) {
attribute.value = value;
return true; // 설정 + 반환
}
return false;
}
// 호출하는 코드가 혼란스럽다
if (setAndCheckAttribute('username', 'alice')) {
// ???
}
Good:
function attributeExists(name: string): boolean {
return findAttribute(name) !== null;
}
function setAttribute(name: string, value: string): void {
const attribute = findAttribute(name);
if (!attribute) throw new AttributeNotFoundError(name);
attribute.value = value;
}
// 호출하는 코드가 명확하다
if (attributeExists('username')) {
setAttribute('username', 'alice');
}
규칙 12: DRY (Don't Repeat Yourself)
중복 코드는 버그의 온상입니다.
Bad:
function getAdminUsers(): User[] {
const users = database.query('SELECT * FROM users');
return users
.filter(u => u.role === 'admin')
.map(u => ({ ...u, displayName: `${u.firstName} ${u.lastName}` }))
.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
function getModeratorUsers(): User[] {
const users = database.query('SELECT * FROM users');
return users
.filter(u => u.role === 'moderator')
.map(u => ({ ...u, displayName: `${u.firstName} ${u.lastName}` }))
.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
Good:
function getUsersByRole(role: UserRole): User[] {
const users = database.query('SELECT * FROM users WHERE role = ?', [role]);
return users
.map(addDisplayName)
.sort(byDisplayName);
}
function addDisplayName(user: User): User {
return { ...user, displayName: `${user.firstName} ${user.lastName}` };
}
function byDisplayName(a: User, b: User): number {
return a.displayName.localeCompare(b.displayName);
}
카테고리 3: 주석 (Comments) — 4가지 규칙
규칙 13: "왜"를 설명하고, "무엇"은 코드에 맡겨라
코드 자체로 "무엇"을 표현하고, 주석은 "왜 이렇게 했는지"를 설명해야 합니다.
Bad:
// 사용자 이름을 가져온다
const userName = user.getName();
// 배열을 순회한다
for (const item of items) {
// 아이템을 처리한다
processItem(item);
}
// 1을 더한다
count += 1;
Good:
// 외부 API 제한으로 최대 100건까지만 요청 가능 (API docs: https://...)
const MAX_BATCH_SIZE = 100;
// 레거시 시스템 호환을 위해 ISO-8859-1로 인코딩해야 함
// 새 시스템 전환 후 UTF-8로 변경 예정 (JIRA-4521)
const encodedData = encode(data, 'ISO-8859-1');
// 동시성 이슈로 인해 락을 먼저 획득해야 함
// 자세한 내용: https://wiki.internal/concurrency-fix-2024
await acquireLock(resourceId);
규칙 14: 노이즈 주석을 피하라
아무런 정보도 추가하지 않는 주석은 코드를 더 읽기 어렵게 만듭니다.
Bad:
/**
* 생성자
*/
constructor() {}
/**
* id를 반환한다
* @returns id
*/
getId(): number {
return this.id;
}
// 기본 생성자
// 이 클래스는 사용자를 나타냅니다
// 작성자: 홍길동
// 작성일: 2024-01-01
// 수정일: 2024-06-15
// 수정자: 김철수
Good:
// 주석이 필요 없을 만큼 명확한 코드
class User {
constructor(
private readonly id: number,
private readonly name: string,
) {}
getId(): number {
return this.id;
}
}
규칙 15: TODO 주석은 표준 형식을 따르라
TODO는 해결해야 할 기술 부채를 추적하는 유용한 도구입니다.
Bad:
// TODO 나중에 고치기
// FIXME 이거 작동 안 함
// HACK 임시로 이렇게 함
// XXX 위험한 코드
Good:
// TODO(team-auth): OAuth2 토큰 갱신 로직 추가 [JIRA-1234] @2025-Q2
// FIXME(alice): 동시 요청 시 race condition 발생. 분산 락 도입 필요 [JIRA-5678]
// HACK: Safari 17.2 flexbox 버그 워크어라운드. Safari 18에서 수정 예정 후 제거
TODO 주석 형식:
// TODO(담당자/팀): 설명 [이슈번호] @예상시기
// FIXME(담당자): 버그 설명 [이슈번호]
// HACK: 워크어라운드 설명. 제거 조건 명시
규칙 16: JSDoc/Docstring을 활용하라
공개 API에는 구조화된 문서를 작성하세요.
TypeScript (JSDoc):
/**
* 주어진 기간 동안의 사용자 활동 통계를 계산합니다.
*
* @param userId - 통계를 조회할 사용자 ID
* @param startDate - 조회 시작일 (포함)
* @param endDate - 조회 종료일 (포함)
* @returns 활동 통계 객체
* @throws {UserNotFoundError} 사용자가 존재하지 않는 경우
* @throws {InvalidDateRangeError} 시작일이 종료일보다 나중인 경우
*
* @example
* const stats = await getUserActivityStats('user-123', '2025-01-01', '2025-03-31');
* console.log(stats.totalLogins); // 45
*/
async function getUserActivityStats(
userId: string,
startDate: string,
endDate: string,
): Promise<ActivityStats> {
// ...
}
Python (Docstring):
def get_user_activity_stats(
user_id: str,
start_date: str,
end_date: str,
) -> ActivityStats:
"""주어진 기간 동안의 사용자 활동 통계를 계산합니다.
Args:
user_id: 통계를 조회할 사용자 ID
start_date: 조회 시작일 (포함, ISO 8601 형식)
end_date: 조회 종료일 (포함, ISO 8601 형식)
Returns:
ActivityStats: 활동 통계 객체
Raises:
UserNotFoundError: 사용자가 존재하지 않는 경우
InvalidDateRangeError: 시작일이 종료일보다 나중인 경우
Example:
>>> stats = get_user_activity_stats('user-123', '2025-01-01', '2025-03-31')
>>> stats.total_logins
45
"""
...
카테고리 4: 에러 처리 (Error Handling) — 4가지 규칙
규칙 17: 에러 코드 대신 예외를 사용하라
에러 코드는 호출자에게 즉각적인 처리를 강제하여 코드를 복잡하게 만듭니다.
Bad:
function withdraw(account: Account, amount: number): number {
if (amount > account.balance) return -1; // 잔액 부족
if (amount < 0) return -2; // 음수 금액
if (account.isFrozen) return -3; // 계좌 동결
account.balance -= amount;
return 0; // 성공
}
// 호출하는 코드
const result = withdraw(myAccount, 100);
if (result === -1) {
// 잔액 부족 처리
} else if (result === -2) {
// 음수 금액 처리
} else if (result === -3) {
// 동결 계좌 처리
}
Good:
class InsufficientFundsError extends Error {
constructor(public readonly balance: number, public readonly amount: number) {
super(`Insufficient funds: balance ${balance}, requested ${amount}`);
this.name = 'InsufficientFundsError';
}
}
class AccountFrozenError extends Error {
constructor(public readonly accountId: string) {
super(`Account ${accountId} is frozen`);
this.name = 'AccountFrozenError';
}
}
function withdraw(account: Account, amount: number): void {
if (amount < 0) throw new InvalidAmountError(amount);
if (account.isFrozen) throw new AccountFrozenError(account.id);
if (amount > account.balance) throw new InsufficientFundsError(account.balance, amount);
account.balance -= amount;
}
// 호출하는 코드
try {
withdraw(myAccount, 100);
} catch (error) {
if (error instanceof InsufficientFundsError) {
showAlert(`잔액이 부족합니다. 현재 잔액: ${error.balance}`);
}
}
규칙 18: 빠르게 실패하라 (Fail Fast)
잘못된 상태를 최대한 빨리 감지하고, 즉시 알려야 합니다.
Bad:
function processPayment(orderId: string, amount: number): PaymentResult {
// ... 100줄의 로직 ...
// 마지막에서야 입력값 검증
if (!orderId) {
return { success: false, error: 'Invalid order ID' };
}
if (amount < 0) {
return { success: false, error: 'Invalid amount' };
}
// ... 실제 결제 처리 ...
}
Good:
function processPayment(orderId: string, amount: number): PaymentResult {
// 가드 절(Guard Clause): 가장 먼저 검증
if (!orderId) throw new InvalidArgumentError('orderId is required');
if (amount <= 0) throw new InvalidArgumentError('amount must be positive');
// 핵심 로직만 남는다
const order = orderRepository.findById(orderId);
if (!order) throw new OrderNotFoundError(orderId);
return paymentGateway.charge(order, amount);
}
규칙 19: 커스텀 에러를 정의하라
비즈니스 로직에 맞는 에러 계층 구조를 만드세요.
// 에러 계층 구조
abstract class AppError extends Error {
abstract readonly statusCode: number;
abstract readonly isOperational: boolean;
constructor(message: string, public readonly cause?: Error) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
readonly statusCode = 404;
readonly isOperational = true;
}
class ValidationError extends AppError {
readonly statusCode = 400;
readonly isOperational = true;
constructor(
message: string,
public readonly field: string,
public readonly value: unknown,
) {
super(message);
}
}
class DatabaseError extends AppError {
readonly statusCode = 500;
readonly isOperational = false; // 시스템 에러 — 운영자에게 알림
}
Python 버전:
class AppError(Exception):
"""애플리케이션 기본 에러"""
status_code: int = 500
is_operational: bool = True
def __init__(self, message: str, cause: Exception | None = None):
super().__init__(message)
self.cause = cause
class NotFoundError(AppError):
status_code = 404
class ValidationError(AppError):
status_code = 400
def __init__(self, message: str, field: str, value: object):
super().__init__(message)
self.field = field
self.value = value
class DatabaseError(AppError):
status_code = 500
is_operational = False
규칙 20: 범용 예외를 catch하지 마라
catch (error) 또는 except Exception은 예상치 못한 에러를 숨깁니다.
Bad:
try {
const data = await fetchUserData(userId);
await processData(data);
await saveToDatabase(data);
} catch (error) {
// 모든 에러를 무시한다! 네트워크 에러? DB 에러? 타입 에러? 전부 삼킨다
console.log('Something went wrong');
}
Good:
try {
const data = await fetchUserData(userId);
await processData(data);
await saveToDatabase(data);
} catch (error) {
if (error instanceof NetworkError) {
logger.warn('Network error fetching user data', { userId, error });
await retryWithBackoff(() => fetchUserData(userId));
} else if (error instanceof ValidationError) {
logger.error('Invalid user data', { userId, field: error.field });
throw error; // 상위로 전파
} else if (error instanceof DatabaseError) {
logger.fatal('Database error', { error });
alertOpsTeam(error);
throw error;
} else {
// 예상하지 못한 에러는 반드시 re-throw
throw error;
}
}
카테고리 5: 코드 구조화 (Code Organization) — 5가지 규칙
규칙 21: 수직 형식을 지켜라 (Vertical Formatting)
연관된 코드는 가까이, 비연관 코드는 빈 줄로 구분하세요.
Bad:
import { Logger } from './logger';
import { Database } from './database';
import { UserRepository } from './repositories/user';
const MAX_RETRIES = 3;
const logger = new Logger('UserService');
export class UserService {
private db: Database;
private repo: UserRepository;
constructor(db: Database) {
this.db = db;
this.repo = new UserRepository(db);
}
async getUser(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) throw new NotFoundError('User not found');
return user;
}
async createUser(data: CreateUserDTO): Promise<User> {
const existing = await this.repo.findByEmail(data.email);
if (existing) throw new ConflictError('Email already exists');
return this.repo.create(data);
}
}
Good:
import { Logger } from './logger';
import { Database } from './database';
import { UserRepository } from './repositories/user';
const MAX_RETRIES = 3;
const logger = new Logger('UserService');
export class UserService {
private db: Database;
private repo: UserRepository;
constructor(db: Database) {
this.db = db;
this.repo = new UserRepository(db);
}
async getUser(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) throw new NotFoundError('User not found');
return user;
}
async createUser(data: CreateUserDTO): Promise<User> {
const existing = await this.repo.findByEmail(data.email);
if (existing) throw new ConflictError('Email already exists');
return this.repo.create(data);
}
}
규칙 22: 신문 기사 은유 (Newspaper Metaphor)
파일을 위에서 아래로 읽을 때, 추상도가 점점 내려가야 합니다.
// 1. 가장 먼저: 퍼블릭 인터페이스 (헤드라인)
export class OrderProcessor {
async processOrder(orderId: string): Promise<OrderResult> {
const order = await this.validateAndFetchOrder(orderId);
const pricing = this.calculatePricing(order);
return this.executePayment(order, pricing);
}
// 2. 중간: 비즈니스 로직 (핵심 내용)
private async validateAndFetchOrder(orderId: string): Promise<Order> {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new OrderNotFoundError(orderId);
this.validateOrderState(order);
return order;
}
private calculatePricing(order: Order): Pricing {
const subtotal = this.sumItems(order.items);
const discount = this.applyDiscounts(subtotal, order.coupons);
const tax = this.calculateTax(subtotal - discount);
return { subtotal, discount, tax, total: subtotal - discount + tax };
}
// 3. 마지막: 세부 구현 (디테일)
private validateOrderState(order: Order): void { /* ... */ }
private sumItems(items: OrderItem[]): number { /* ... */ }
private applyDiscounts(amount: number, coupons: Coupon[]): number { /* ... */ }
private calculateTax(amount: number): number { /* ... */ }
}
규칙 23: Import 정리
Import 문은 일관된 규칙으로 정렬하세요.
// 1. Node.js 내장 모듈
import { readFile } from 'fs/promises';
import path from 'path';
// 2. 외부 라이브러리 (node_modules)
import express from 'express';
import { z } from 'zod';
// 3. 내부 모듈 — 절대 경로
import { AppConfig } from '@/config';
import { Logger } from '@/utils/logger';
// 4. 내부 모듈 — 상대 경로
import { UserService } from './services/user';
import { OrderService } from './services/order';
// 5. 타입 Import (TypeScript)
import type { User, Order } from './types';
ESLint 자동 정렬 설정:
{
"rules": {
"import/order": [
"error",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "type"],
"newlines-between": "always",
"alphabetize": { "order": "asc" }
}
]
}
}
규칙 24: 파일 크기를 제한하라
| 기준 | 권장 | 경고 | 위험 |
|---|---|---|---|
| 파일 줄 수 | 200줄 이하 | 200~400줄 | 400줄 이상 |
| 함수 줄 수 | 20줄 이하 | 20~50줄 | 50줄 이상 |
| 클래스 메서드 수 | 10개 이하 | 10~20개 | 20개 이상 |
| 매개변수 수 | 2개 이하 | 3~4개 | 5개 이상 |
| 중첩 깊이 | 2단계 이하 | 3단계 | 4단계 이상 |
규칙 25: 모듈 경계를 명확히 하라
src/
├── modules/
│ ├── user/
│ │ ├── index.ts # 퍼블릭 API만 export
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.controller.ts
│ │ ├── user.types.ts
│ │ └── __tests__/
│ │ ├── user.service.test.ts
│ │ └── user.repository.test.ts
│ ├── order/
│ │ ├── index.ts
│ │ ├── order.service.ts
│ │ └── ...
│ └── payment/
│ ├── index.ts
│ └── ...
├── shared/
│ ├── errors/
│ ├── utils/
│ └── types/
└── infrastructure/
├── database/
├── cache/
└── messaging/
핵심 원칙: 모듈은 index.ts를 통해서만 외부에 공개합니다. 내부 구현은 숨깁니다.
// modules/user/index.ts — 퍼블릭 API만 노출
export { UserService } from './user.service';
export type { User, CreateUserDTO } from './user.types';
// UserRepository는 export하지 않는다 — 내부 구현
카테고리 6: 테스트 (Testing) — 5가지 규칙
규칙 26: FIRST 원칙
| 원칙 | 설명 |
|---|---|
| Fast | 테스트는 빠르게 실행되어야 한다 (ms 단위) |
| Independent | 테스트 간 의존성이 없어야 한다 |
| Repeatable | 어떤 환경에서든 동일한 결과 |
| Self-validating | Pass/Fail이 자동으로 판단되어야 한다 |
| Timely | 프로덕션 코드 직전/직후에 작성 |
규칙 27: AAA 패턴 (Arrange-Act-Assert)
describe('UserService', () => {
describe('createUser', () => {
it('should create user with valid data', async () => {
// Arrange — 테스트 데이터 준비
const userRepo = new InMemoryUserRepository();
const service = new UserService(userRepo);
const userData: CreateUserDTO = {
name: 'Alice',
email: 'alice@example.com',
};
// Act — 테스트 대상 실행
const result = await service.createUser(userData);
// Assert — 결과 검증
expect(result.name).toBe('Alice');
expect(result.email).toBe('alice@example.com');
expect(result.id).toBeDefined();
expect(await userRepo.count()).toBe(1);
});
});
});
규칙 28: 하나의 테스트에 하나의 Assert 개념
Bad:
it('should handle user lifecycle', async () => {
// 생성, 조회, 수정, 삭제를 모두 테스트
const user = await service.createUser(data);
expect(user.id).toBeDefined();
const fetched = await service.getUser(user.id);
expect(fetched.name).toBe(data.name);
await service.updateUser(user.id, { name: 'Bob' });
const updated = await service.getUser(user.id);
expect(updated.name).toBe('Bob');
await service.deleteUser(user.id);
await expect(service.getUser(user.id)).rejects.toThrow();
});
Good:
describe('UserService', () => {
it('should create user and return with generated id', async () => {
const user = await service.createUser(validUserData);
expect(user.id).toBeDefined();
expect(user.name).toBe(validUserData.name);
});
it('should retrieve existing user by id', async () => {
const created = await service.createUser(validUserData);
const fetched = await service.getUser(created.id);
expect(fetched).toEqual(created);
});
it('should update user name', async () => {
const user = await service.createUser(validUserData);
await service.updateUser(user.id, { name: 'Bob' });
const updated = await service.getUser(user.id);
expect(updated.name).toBe('Bob');
});
it('should throw NotFoundError when getting deleted user', async () => {
const user = await service.createUser(validUserData);
await service.deleteUser(user.id);
await expect(service.getUser(user.id)).rejects.toThrow(NotFoundError);
});
});
규칙 29: 테스트를 읽기 쉽게 작성하라
Bad:
it('test1', async () => {
const r = await s.calc({ a: 100, b: 2, c: 0.1, d: true });
expect(r).toBe(180);
});
Good:
it('should apply 10% discount when premium member orders 2 items at 100 each', async () => {
// Given
const order = createOrder({
items: [{ price: 100, quantity: 2 }],
isPremiumMember: true,
discountRate: 0.1,
});
// When
const totalPrice = await pricingService.calculateTotal(order);
// Then
expect(totalPrice).toBe(180); // (100 * 2) - 10% discount = 180
});
규칙 30: 테스트 이름은 시나리오를 설명하라
네이밍 패턴: should [예상 결과] when [조건]
describe('PasswordValidator', () => {
// Good: 시나리오가 명확하다
it('should return valid when password has 8+ chars with mixed case and numbers', () => {});
it('should return invalid when password is shorter than 8 characters', () => {});
it('should return invalid when password lacks uppercase letters', () => {});
it('should return invalid when password lacks numbers', () => {});
it('should throw EmptyPasswordError when password is empty string', () => {});
});
Python (pytest) 스타일:
class TestPasswordValidator:
def test_valid_password_with_mixed_case_and_numbers(self):
"""8자 이상, 대소문자 혼합, 숫자 포함 시 유효"""
...
def test_invalid_when_shorter_than_8_chars(self):
"""8자 미만 비밀번호는 무효"""
...
def test_raises_error_when_password_is_empty(self):
"""빈 문자열 입력 시 에러 발생"""
...
코드 냄새 카탈로그 (Code Smells)
Martin Fowler의 리팩토링에서 정의한 대표적인 코드 냄새와 해결책입니다.
| 코드 냄새 | 증상 | 해결책 |
|---|---|---|
| Long Method | 함수가 50줄 이상 | Extract Method |
| Large Class | 클래스의 책임이 3가지 이상 | Extract Class |
| Primitive Obsession | 원시 타입만으로 도메인 표현 | Value Object 도입 |
| Long Parameter List | 매개변수 4개 이상 | Parameter Object |
| Data Clumps | 여러 곳에서 같은 데이터 그룹이 반복 | Extract Class |
| Feature Envy | 다른 클래스의 데이터를 과도하게 사용 | Move Method |
| Shotgun Surgery | 하나의 변경이 여러 클래스에 영향 | Move Method, Inline Class |
| Divergent Change | 하나의 클래스가 다양한 이유로 변경 | Extract Class |
| Comments | 코드를 설명하는 주석이 과도 | Rename, Extract Method |
| Dead Code | 사용되지 않는 코드 | 삭제 |
| Speculative Generality | 미래를 위한 과도한 추상화 | Collapse Hierarchy |
| Duplicated Code | 같은 코드가 2곳 이상 | Extract Method, Template Method |
리팩토링 기법 Before/After
기법 1: 가드 절(Guard Clause)로 중첩 제거
Before:
function getPaymentAmount(employee: Employee): number {
let result: number;
if (employee.isSeparated) {
result = separatedAmount();
} else {
if (employee.isRetired) {
result = retiredAmount();
} else {
result = normalPayAmount();
}
}
return result;
}
After:
function getPaymentAmount(employee: Employee): number {
if (employee.isSeparated) return separatedAmount();
if (employee.isRetired) return retiredAmount();
return normalPayAmount();
}
기법 2: 전략 패턴으로 조건문 제거
Before:
function calculateShippingCost(order: Order): number {
if (order.shippingMethod === 'standard') {
return order.weight * 1.5;
} else if (order.shippingMethod === 'express') {
return order.weight * 3.0 + 5;
} else if (order.shippingMethod === 'overnight') {
return order.weight * 5.0 + 15;
} else if (order.shippingMethod === 'international') {
return order.weight * 10.0 + 25;
}
throw new Error('Unknown shipping method');
}
After:
interface ShippingStrategy {
calculate(weight: number): number;
}
const shippingStrategies: Record<string, ShippingStrategy> = {
standard: { calculate: (w) => w * 1.5 },
express: { calculate: (w) => w * 3.0 + 5 },
overnight: { calculate: (w) => w * 5.0 + 15 },
international: { calculate: (w) => w * 10.0 + 25 },
};
function calculateShippingCost(order: Order): number {
const strategy = shippingStrategies[order.shippingMethod];
if (!strategy) throw new UnknownShippingMethodError(order.shippingMethod);
return strategy.calculate(order.weight);
}
기법 3: 파이프라인으로 루프 리팩토링
Before:
function getActiveAdminEmails(users: User[]): string[] {
const result: string[] = [];
for (let i = 0; i < users.length; i++) {
if (users[i].isActive) {
if (users[i].role === 'admin') {
result.push(users[i].email.toLowerCase());
}
}
}
result.sort();
return result;
}
After:
function getActiveAdminEmails(users: User[]): string[] {
return users
.filter(user => user.isActive)
.filter(user => user.role === 'admin')
.map(user => user.email.toLowerCase())
.sort();
}
인지 복잡도 (Cognitive Complexity)
SonarQube에서 측정하는 인지 복잡도는 코드를 "이해하는 데 필요한 정신적 노력"을 수치화합니다.
인지 복잡도 증가 요인
| 요인 | 증가량 | 예시 |
|---|---|---|
| if, else if, else | +1 | 분기 |
| switch case | +1 | 분기 |
| for, while, do-while | +1 | 반복 |
| catch | +1 | 예외 처리 |
| break/continue to label | +1 | 흐름 변경 |
| 중첩 (nesting) | +1 per level | 중첩된 if/for |
| 논리 연산자 체인 변경 | +1 | a && b || c |
| 재귀 호출 | +1 | 자기 참조 |
인지 복잡도 예시
// 인지 복잡도: 13 (높음!)
function processData(items: Item[]): Result[] { // +0
const results: Result[] = [];
for (const item of items) { // +1 (루프)
if (item.isValid) { // +2 (if + 중첩1)
if (item.type === 'A') { // +3 (if + 중첩2)
results.push(processTypeA(item));
} else if (item.type === 'B') { // +1 (else if)
for (const sub of item.children) { // +4 (루프 + 중첩3)
if (sub.isActive) { // +5 (if + 중첩4) -- 이미 이해 불가
results.push(processTypeB(sub));
}
}
}
}
}
return results; // 총: 1+2+3+1+4+5 = 16
}
리팩토링 후 인지 복잡도: 4
function processData(items: Item[]): Result[] {
return items
.filter(item => item.isValid)
.flatMap(item => processByType(item));
}
function processByType(item: Item): Result[] {
switch (item.type) { // +1
case 'A': return [processTypeA(item)];
case 'B': return processActiveBChildren(item);
default: return [];
}
}
function processActiveBChildren(item: Item): Result[] {
return item.children
.filter(child => child.isActive)
.map(child => processTypeB(child));
}
권장 임계값:
| 수준 | 인지 복잡도 | 조치 |
|---|---|---|
| 좋음 | 0~5 | 유지 |
| 경고 | 6~10 | 리팩토링 검토 |
| 위험 | 11~15 | 반드시 리팩토링 |
| 심각 | 16 이상 | 즉시 리팩토링 |
코드 리뷰 체크리스트
PR 리뷰 시 확인할 항목
## 네이밍
- [ ] 변수/함수/클래스 이름이 의도를 드러내는가?
- [ ] 약어를 사용하지 않았는가?
- [ ] 팀 네이밍 컨벤션을 따르는가?
## 함수
- [ ] 함수가 한 가지 일만 하는가?
- [ ] 매개변수가 3개 이하인가?
- [ ] 사이드 이펙트가 없는가?
## 에러 처리
- [ ] 적절한 커스텀 에러를 사용하는가?
- [ ] 에러를 삼키지 않는가?
- [ ] 가드 절로 빠르게 실패하는가?
## 테스트
- [ ] 새 기능에 대한 테스트가 있는가?
- [ ] 엣지 케이스를 커버하는가?
- [ ] 테스트 이름이 시나리오를 설명하는가?
## 코드 구조
- [ ] 파일이 400줄을 넘지 않는가?
- [ ] 중첩 깊이가 3단계 이하인가?
- [ ] DRY 원칙을 따르는가?
## 보안
- [ ] 사용자 입력을 검증하는가?
- [ ] SQL 인젝션, XSS 가능성은 없는가?
- [ ] 민감 정보를 로그에 남기지 않는가?
SOLID 원칙 빠른 참조
S — 단일 책임 원칙 (Single Responsibility Principle)
// Bad: 한 클래스가 여러 책임
class UserService {
createUser(data: CreateUserDTO) {} // 비즈니스 로직
sendEmail(to: string, body: string) {} // 이메일 전송
generatePDF(user: User) {} // PDF 생성
logAction(action: string) {} // 로깅
}
// Good: 각 클래스가 하나의 책임
class UserService { createUser(data: CreateUserDTO) {} }
class EmailService { send(to: string, body: string) {} }
class PDFGenerator { generate(user: User) {} }
class AuditLogger { log(action: string) {} }
O — 개방-폐쇄 원칙 (Open/Closed Principle)
// 확장에는 열려 있고, 수정에는 닫혀 있어야 한다
interface PaymentProcessor {
process(amount: number): Promise<PaymentResult>;
}
class StripeProcessor implements PaymentProcessor {
async process(amount: number) { /* Stripe API */ }
}
class PayPalProcessor implements PaymentProcessor {
async process(amount: number) { /* PayPal API */ }
}
// 새 결제 수단 추가 시 기존 코드 수정 없이 새 클래스만 추가
class CryptoProcessor implements PaymentProcessor {
async process(amount: number) { /* Crypto API */ }
}
L — 리스코프 치환 원칙 (Liskov Substitution Principle)
// 하위 타입은 상위 타입을 대체할 수 있어야 한다
class Bird {
fly(): void { /* 날아간다 */ }
}
// Bad: 펭귄은 날 수 없으므로 Bird를 상속하면 안 된다
class Penguin extends Bird {
fly(): void { throw new Error('Cannot fly!'); }
}
// Good: 인터페이스 분리
interface FlyingBird { fly(): void; }
interface SwimmingBird { swim(): void; }
class Eagle implements FlyingBird { fly() {} }
class Penguin implements SwimmingBird { swim() {} }
I — 인터페이스 분리 원칙 (Interface Segregation Principle)
// Bad: 뚱뚱한 인터페이스
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
}
// Good: 분리된 인터페이스
interface Workable { work(): void; }
interface Feedable { eat(): void; }
interface Meetable { attendMeeting(): void; }
class Developer implements Workable, Feedable, Meetable {
work() {}
eat() {}
attendMeeting() {}
}
class Robot implements Workable {
work() {}
// eat(), attendMeeting() 필요 없음
}
D — 의존성 역전 원칙 (Dependency Inversion Principle)
// Bad: 고수준 모듈이 저수준 모듈에 직접 의존
class OrderService {
private mysqlDb = new MySQLDatabase(); // 구체 클래스에 의존
save(order: Order) {
this.mysqlDb.insert('orders', order);
}
}
// Good: 추상화에 의존
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
class OrderService {
constructor(private readonly repo: OrderRepository) {}
async save(order: Order) {
await this.repo.save(order);
}
}
// 구체 구현은 별도로
class MySQLOrderRepository implements OrderRepository { /* ... */ }
class MongoOrderRepository implements OrderRepository { /* ... */ }
class InMemoryOrderRepository implements OrderRepository { /* ... */ } // 테스트용
실전 퀴즈
퀴즈 1: 다음 코드의 문제점은 무엇인가요?
function proc(lst: any[], flg: boolean): any[] {
let res: any[] = [];
for (let i = 0; i < lst.length; i++) {
if (flg) {
if (lst[i].a > 0) {
res.push(lst[i].a * 2);
}
} else {
if (lst[i].b !== null) {
res.push(lst[i].b);
}
}
}
return res;
}
문제점:
- 네이밍: proc, lst, flg, res, a, b 모두 의미 없는 이름
- 타입: any 타입 남용
- 함수 책임: 플래그로 동작이 달라지는 건 두 개의 함수가 필요하다는 신호
- 중첩 깊이: 3단계 중첩
- 명령형 루프: filter/map 파이프라인으로 대체 가능
리팩토링:
function getDoubledPositiveAmounts(transactions: Transaction[]): number[] {
return transactions
.filter(t => t.amount > 0)
.map(t => t.amount * 2);
}
function getNonNullBalances(accounts: Account[]): number[] {
return accounts
.filter(a => a.balance !== null)
.map(a => a.balance);
}
퀴즈 2: 이 에러 처리 코드를 개선하세요.
async function getData(id: string) {
try {
const res = await fetch(`/api/data/${id}`);
const data = await res.json();
return data;
} catch (e) {
console.log(e);
return null;
}
}
문제점:
- 모든 에러를 삼킨다 (catch에서 null 반환)
- 에러 로깅이
console.log로 부실하다 - HTTP 상태 코드를 확인하지 않는다
- 반환 타입이 불명확하다 (null 가능)
리팩토링:
class ApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly endpoint: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function fetchDataById(id: string): Promise<UserData> {
const endpoint = `/api/data/${id}`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new ApiError(
`Failed to fetch data: ${response.statusText}`,
response.status,
endpoint,
);
}
return response.json() as Promise<UserData>;
}
퀴즈 3: 이 클래스의 SOLID 위반을 찾으세요.
class ReportGenerator {
generateReport(data: any[]): string {
// 데이터 검증
if (!data.length) throw new Error('No data');
// HTML 생성
let html = '<table>';
for (const row of data) {
html += `<tr><td>${row.name}</td><td>${row.value}</td></tr>`;
}
html += '</table>';
// 파일 저장
fs.writeFileSync('/tmp/report.html', html);
// 이메일 전송
sendEmail('admin@company.com', 'Report', html);
return html;
}
}
위반 사항:
- SRP 위반: 검증, HTML 생성, 파일 저장, 이메일 전송 — 4가지 책임
- OCP 위반: PDF 형식 추가 시 클래스 수정 필요
- DIP 위반: fs, sendEmail에 직접 의존
리팩토링:
interface ReportFormatter {
format(data: ReportData[]): string;
}
interface ReportExporter {
export(content: string): Promise<void>;
}
class HtmlFormatter implements ReportFormatter {
format(data: ReportData[]): string { /* ... */ }
}
class FileExporter implements ReportExporter {
async export(content: string) { /* ... */ }
}
class EmailExporter implements ReportExporter {
async export(content: string) { /* ... */ }
}
class ReportService {
constructor(
private formatter: ReportFormatter,
private exporters: ReportExporter[],
) {}
async generate(data: ReportData[]): Promise<string> {
if (!data.length) throw new EmptyDataError();
const content = this.formatter.format(data);
await Promise.all(this.exporters.map(e => e.export(content)));
return content;
}
}
퀴즈 4: 다음 함수의 인지 복잡도를 계산하세요.
function calculatePrice(product: Product, user: User): number {
let price = product.basePrice;
if (user.isPremium) { // +1
if (product.category === 'electronics') { // +2 (중첩)
price *= 0.8;
} else { // +1
price *= 0.9;
}
} else { // +1
if (user.hasCoupon) { // +2 (중첩)
price *= 0.95;
}
}
if (product.isOnSale && user.isPremium) { // +1
price *= 0.9;
}
return price;
}
인지 복잡도: 8 (경고 수준, 리팩토링 검토 필요)
리팩토링:
function calculatePrice(product: Product, user: User): number {
const basePrice = product.basePrice;
const memberDiscount = getMemberDiscount(user, product.category);
const couponDiscount = getCouponDiscount(user);
const saleDiscount = getSaleDiscount(product, user);
return basePrice * memberDiscount * couponDiscount * saleDiscount;
}
function getMemberDiscount(user: User, category: string): number {
if (!user.isPremium) return 1;
return category === 'electronics' ? 0.8 : 0.9;
}
function getCouponDiscount(user: User): number {
return !user.isPremium && user.hasCoupon ? 0.95 : 1;
}
function getSaleDiscount(product: Product, user: User): number {
return product.isOnSale && user.isPremium ? 0.9 : 1;
}
퀴즈 5: 이 테스트 코드를 개선하세요.
test('test1', () => {
const s = new CalcService();
expect(s.add(1, 2)).toBe(3);
expect(s.add(-1, 1)).toBe(0);
expect(s.add(0, 0)).toBe(0);
expect(s.subtract(5, 3)).toBe(2);
expect(s.multiply(3, 4)).toBe(12);
expect(s.divide(10, 2)).toBe(5);
expect(() => s.divide(1, 0)).toThrow();
});
문제점:
- 테스트 이름이 의미 없다
- 하나의 테스트에서 여러 기능을 검증
- 실패 시 어떤 검증이 실패했는지 파악 어려움
리팩토링:
describe('CalcService', () => {
const calculator = new CalcService();
describe('add', () => {
it('should return sum of two positive numbers', () => {
expect(calculator.add(1, 2)).toBe(3);
});
it('should handle negative numbers', () => {
expect(calculator.add(-1, 1)).toBe(0);
});
it('should return zero when both inputs are zero', () => {
expect(calculator.add(0, 0)).toBe(0);
});
});
describe('divide', () => {
it('should return quotient of two numbers', () => {
expect(calculator.divide(10, 2)).toBe(5);
});
it('should throw DivisionByZeroError when divisor is zero', () => {
expect(() => calculator.divide(1, 0)).toThrow(DivisionByZeroError);
});
});
});
도구와 자동화
ESLint + Prettier 설정
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"max-lines": ["warn", { "max": 400 }],
"max-lines-per-function": ["warn", { "max": 50 }],
"max-params": ["warn", { "max": 3 }],
"max-depth": ["warn", { "max": 3 }],
"complexity": ["warn", { "max": 10 }],
"no-else-return": "error",
"prefer-const": "error",
"no-var": "error"
}
}
SonarQube Quality Gate
# sonar-project.properties
sonar.qualitygate.conditions:
- metric: cognitive_complexity
operator: GT
error: 15
- metric: duplicated_lines_density
operator: GT
error: 3
- metric: coverage
operator: LT
error: 80
참고 자료
- Robert C. Martin, "Clean Code: A Handbook of Agile Software Craftsmanship" (2008)
- Martin Fowler, "Refactoring: Improving the Design of Existing Code" (2018, 2nd Edition)
- G. Ann Campbell, "Cognitive Complexity: A new way of measuring understandability" — SonarSource (2023)
- Google Engineering Practices — Code Review Guidelines
- Airbnb JavaScript Style Guide
- TypeScript Deep Dive — Style Guide
- PEP 8 — Python Style Guide
- "The Art of Readable Code" by Dustin Boswell and Trevor Foucher (2011)
- SonarQube Documentation — Cognitive Complexity
- ESLint Rules Reference
- Kent Beck, "Test Driven Development: By Example" (2002)
- Michael Feathers, "Working Effectively with Legacy Code" (2004)
- "A Philosophy of Software Design" by John Ousterhout (2018)
- Clean Code JavaScript — GitHub (ryanmcdermott/clean-code-javascript)