코딩 스타일 대탐험! 🧐 절차형 vs 객체지향 vs 함수형, 뭐가 다르고 언제 쓸까?

홍태극·2025년 4월 16일
0
post-thumbnail

코딩 스타일 대탐험! 🧐 절차형 vs 객체지향 vs 함수형, 뭐가 다르고 언제 쓸까?

코딩을 하다 보면 사람마다 코드를 짜는 스타일이 조금씩 다르다는 걸 느끼게 되죠? 그런데 이런 '스타일'을 넘어서, 프로그래밍 세계에는 아예 프로그램을 설계하고 구성하는 방식 자체에 대한 여러 가지 '철학' 또는 패러다임이라는 것이 존재해요. 그중 가장 대표적인 삼대장이 바로 절차형, 객체 지향(OOP), 함수형(FP) 프로그래밍이랍니다! 😎

"아니, 그냥 코드 짜면 되는 거 아니었어?" 싶으실 수도 있지만, 이 패러다임들을 이해하면 문제를 바라보는 시야가 넓어지고, 상황에 맞는 더 좋은 코드를 작성하는 데 큰 도움이 돼요. 특히 요즘 많이 쓰는 타입스크립트 같은 언어는 여러 패러다임의 장점을 섞어 쓸 수 있거든요!

오늘은 이 세 가지 프로그래밍 패러다임이 각각 어떤 특징을 가지고 있는지, 장단점은 뭔지, 그리고 타입스크립트 예제와 함께 어떻게 섞어 쓸 수 있는지 알아보는 시간을 가져볼게요! 자, 코딩 스타일 대탐험 출발! 🚀

1. 📜 레시피 따라 차근차근! 절차형 프로그래밍

절차형 프로그래밍은 가장 고전적이면서 직관적인 방식이에요. 마치 요리 레시피처럼, 프로그램을 정해진 순서대로 실행되는 명령어들의 묶음(함수 또는 프로시저)으로 보는 거죠. 데이터는 데이터대로 있고, 이 데이터를 가지고 어떤 절차를 거쳐 원하는 결과를 만들지에 집중해요. 코드가 위에서 아래로 물 흐르듯 순차적으로 진행되는 게 특징이에요.

👍 장점은요?

  • 로직이 단순하고 순서대로 진행되니까 이해하고 배우기가 쉬워요.

  • 간단한 프로그램이나 짧은 스크립트 만들 때 아주 효율적이에요.

  • 실행 속도도 비교적 빠를 수 있어요.

👎 단점은요?

  • 데이터와 그 데이터를 처리하는 함수가 따로 놀다 보니, 프로그램 규모가 커지면 데이터 관리가 복잡해지고 코드가 중복되기 쉬워요.

  • 여기저기서 데이터를 직접 건드릴 수 있어서(전역 변수 등), 예상치 못한 문제가 생길 가능성도 있고요.

  • 데이터 구조가 바뀌면 관련된 함수들을 전부 찾아서 고쳐야 할 수도 있어요. (유지보수 힘듦 😭)

타입스크립트로 맛보기

간단한 사용자 점수 관리 코드를 절차형 스타일로 짜볼게요.

// 여기저기서 쓸 수 있는 데이터들 (전역 상태)
let currentUserName: string | null = null;
let currentUserScore: number = 0;

// 사용자 이름을 정하는 절차(함수)
function assignUserName(name: string): void {
  currentUserName = name;
  console.log(`사용자 이름 설정 완료 -> ${currentUserName}`);
}

// 점수를 더하는 절차(함수)
function increaseScore(points: number): void {
  if (currentUserName) { // 사용자가 정해졌는지 확인하고
    currentUserScore += points; // 점수 더하기
    console.log(`${currentUserName}님 점수 추가 ${points}점 | 현재 총점 ${currentUserScore}`);
  } else {
    console.log('⚠️ 사용자 이름부터 정해주세요!');
  }
}

// 점수를 리셋하는 절차(함수)
function resetUserScore(): void {
  currentUserScore = 0;
  console.log('점수가 초기화되었습니다.');
}

// 자, 이제 레시피대로 실행해볼까요?
assignUserName('용감한 개발자');
increaseScore(100);
increaseScore(50);
resetUserScore(); // 아차! 점수 초기화
increaseScore(70); // 다시 점수 추가

함수들이 순서대로 호출되면서 currentUserName이나 currentUserScore 같은 데이터를 직접 변경하는 걸 볼 수 있죠? 이게 바로 절차형 스타일이에요.

2. 🧱 레고 블록처럼 조립! 객체 지향 프로그래밍 (OOP)

객체 지향 프로그래밍(Object-Oriented Programming, OOP)은 좀 더 발전된 방식이에요. 데이터와 그 데이터를 다루는 방법(함수 -> 메서드)을 하나의 '객체(Object)'라는 덩어리로 묶어서 관리하는 거죠. 마치 레고 블록처럼, 필요한 기능들을 객체 단위로 미리 만들어놓고, 이 객체들을 조립해서 프로그램을 완성해요.

OOP의 핵심 아이디어는 캡슐화(데이터와 기능을 묶고 내부를 숨김), 상속(부모 객체의 특징을 물려받음), 다형성(같은 이름의 메서드가 객체마다 다르게 동작), 추상화(복잡한 내부 대신 중요한 특징만 보여줌) 같은 것들이 있어요. 현실 세계의 사물이나 개념을 코드로 표현하기 좋고, 코드의 재사용성과 유지보수성을 높이는 데 큰 장점이 있죠.

👍 장점은요?

  • 관련된 데이터와 기능을 객체로 묶으니 코드가 깔끔하게 정리되고 관리하기 편해요. (캡슐화)

  • 한번 잘 만들어둔 객체(클래스)는 다른 곳에서 쉽게 다시 쓰거나 확장할 수 있어요. (상속, 다형성)

  • 현실 세계를 모델링하기 좋아서 복잡한 시스템 설계에 유리해요.

👎 단점은요?

  • 처음부터 객체를 잘 설계하는 게 중요해서 좀 복잡하게 느껴질 수 있어요.

  • 상속 관계가 너무 복잡해지면 오히려 코드를 이해하고 고치기 어려워질 수도 있어요.

  • 객체들이 서로 너무 얽혀있으면(강한 결합) 하나를 고쳤을 때 다른 곳에 영향을 줄 수도 있어요.

타입스크립트로 맛보기

게임 캐릭터를 OOP 스타일로 만들어 볼게요.

// 게임 캐릭터 설계도 (클래스)
class GameCharacter {
  // protected: 나랑 나를 상속받은 자식만 쓸 수 있음!
  protected name: string;
  protected hp: number;
  protected level: number;

  // 캐릭터 생성 시 필요한 정보 받기 (생성자 메서드)
  constructor(name: string, hp: number) {
    this.name = name;
    this.hp = hp;
    this.level = 1; // 레벨은 1부터 시작
    console.log(`${this.name} 등장! (HP: ${this.hp})`);
  }

  // 캐릭터 정보 보여주는 기능 (메서드)
  getInfo(): void {
    console.log(`[${this.name}] Lv.${this.level} | HP: ${this.hp}`);
  }

  // 레벨 올리는 기능 (메서드, 객체 상태 변경)
  levelUp(): void {
    this.level++;
    this.hp += 10; // 레벨 오르면 HP도 10 증가!
    console.log(`🎉 ${this.name} 레벨 업! (Lv.${this.level})`);
    this.getInfo(); // 변경된 정보 다시 보여주기
  }
}

// 마법사 캐릭터 설계도 (GameCharacter를 상속받음!)
class Wizard extends GameCharacter {
  private mp: number; // 마법사만 가지는 MP 속성 (private: 나만 쓸 수 있음!)

  constructor(name: string, hp: number, mp: number) {
    super(name, hp); // 부모(GameCharacter) 생성자 먼저 호출 필수!
    this.mp = mp;
    console.log(`🔮 ${this.name}, 마법의 힘을 깨우치다! (MP: ${this.mp})`);
  }

  // 마법 쓰는 기능 (마법사 고유 메서드)
  castSpell(spellName: string): void {
    if (this.mp >= 10) {
      this.mp -= 10;
      console.log(`🔥 ${this.name}(이)가 ${spellName} 시전! (남은 MP: ${this.mp})`);
    } else {
      console.log('앗! MP가 부족해요...');
    }
  }

  // 부모의 getInfo 메서드를 마법사에게 맞게 재정의 (메서드 오버라이딩 -> 다형성)
  override getInfo(): void {
    console.log(`[마법사 ${this.name}] Lv.${this.level} | HP: ${this.hp} | MP: ${this.mp}`);
  }
}

// 객체(캐릭터) 생성!
const warrior = new GameCharacter('용맹한 전사', 150);
const mage = new Wizard('지혜로운 법사', 80, 100);

warrior.getInfo();
mage.getInfo(); // 마법사는 재정의된 getInfo가 호출돼요!

warrior.levelUp();
mage.castSpell('얼음 화살');
mage.levelUp();

GameCharacterWizard라는 객체 안에 이름, HP, MP 같은 데이터와 getInfo, levelUp, castSpell 같은 기능(메서드)이 함께 들어있죠? 상속을 통해 코드도 재사용하고요. 이게 바로 OOP 스타일!

3. ✨ 수학처럼 깔끔하게! 함수형 프로그래밍 (FP)

함수형 프로그래밍(Functional Programming, FP)은 또 다른 접근 방식이에요. 프로그램을 '순수 함수'들의 조합으로 만들고, '데이터는 변하지 않는다(불변성)'는 원칙을 중요하게 생각해요. 마치 수학 함수처럼, 같은 입력에는 항상 같은 결과가 나오고, 함수가 외부에 영향을 주지 않는(부수 효과 최소화) 것을 추구하죠. 상태 변화를 직접 다루기보다, 데이터의 흐름과 변환에 집중해요.

👍 장점은요?

  • 코드가 예측 가능하고 테스트하기 쉬워요. (순수 함수 덕분!)

  • 데이터가 변하지 않으니 여러 작업을 동시에 처리(동시성)할 때 안전하고 오류가 적어요.

  • 코드가 간결하고 선언적('어떻게' 보다는 '무엇'을 할지만 명시)으로 작성될 때가 많아요. 함수들을 조립해서 쓰기도 좋고요.

👎 단점은요?

  • 순수 함수, 불변성, 고차 함수, 모나드(?) 등 처음 배울 때 개념이 좀 생소할 수 있어요.

  • 데이터를 계속 복사(불변성)하면 성능이 조금 떨어지거나 메모리를 더 쓸 수도 있어요. (물론 최적화 방법들이 있어요!)

  • 입출력처럼 외부 상태와 상호작용하는 부분은 순수하게 만들기 까다로울 수 있어요.

  • 디버깅이 조금 어려울 수도 있고요.

타입스크립트로 맛보기

상품 목록에서 특정 카테고리 상품만 골라 부가세를 적용하는 코드를 FP 스타일로 짜볼게요.

// 상품 정보 타입 (readonly로 불변성 강조!)
type Product = {
  readonly id: string;
  readonly name: string;
  readonly price: number;
  readonly category: string;
};

// 가격에 부가세(10%)를 더하는 순수 함수
function addVat(price: number): number {
  return price * 1.1; // 오직 입력값 price에만 의존!
}

// 상품 객체를 받아서, 부가세가 적용된 '새로운' 상품 객체를 반환하는 순수 함수
function applyVatToProduct(product: Product): Product {
  // ...product 로 기존 객체 내용을 복사하고 (원본 불변!)
  // price 속성만 부가세 적용된 값으로 덮어쓴 새 객체 반환
  return {
    ...product,
    price: addVat(product.price),
  };
}

// 고차 함수와 함수 조합 활용!
// 상품 목록과 카테고리 이름을 받아서, 해당 카테고리 상품들에 부가세를 적용한 '새로운' 목록 반환
function getVatAppliedProductsByCategory(
  products: readonly Product[], // 원본 목록은 불변!
  category: string
): Product[] {
  // 1. filter로 특정 카테고리 상품만 거르고 (고차 함수)
  // 2. map으로 걸러진 각 상품에 applyVatToProduct 함수 적용 (고차 함수)
  return products
    .filter(p => p.category === category) // p.category === category 는 순수한 조건 함수
    .map(applyVatToProduct); // 함수 자체를 인자로 넘김!
}

// 원본 상품 목록 (불변)
const initialProducts: readonly Product[] = [
  { id: 'p1', name: '게이밍 노트북', price: 2000000, category: 'PC' },
  { id: 'p2', name: '기계식 키보드', price: 150000, category: '주변기기' },
  { id: 'p3', name: '웹캠', price: 80000, category: '주변기기' },
  { id: 'p4', name: '만화책 세트', price: 50000, category: '도서' },
];

// '주변기기' 카테고리에 부가세 적용!
const peripheralsWithVat = getVatAppliedProductsByCategory(initialProducts, '주변기기');

console.log('원본 상품 (키보드):', initialProducts[1]); // { id: 'p2', ..., price: 150000, ... } (원본 그대로!)
console.log('부가세 적용된 주변기기 목록:', peripheralsWithVat);
/* 출력 예상:
[
  { id: 'p2', name: '기계식 키보드', price: 165000, category: '주변기기' },
  { id: 'p3', name: '웹캠', price: 88000, category: '주변기기' }
]
*/

데이터(product)를 직접 바꾸지 않고, 순수한 함수(addVat, applyVatToProduct)와 고차 함수(filter, map)를 조합해서 원하는 결과를 만들어냈죠? 데이터의 변환 과정이 명확하게 드러나는 게 FP 스타일의 특징이에요.

🥊 세 가지 스타일, 한눈에 비교해볼까요?

구분절차형 프로그래밍객체 지향 프로그래밍 (OOP)함수형 프로그래밍 (FP)
핵심작업 절차/순서데이터와 기능을 묶은 객체순수 함수불변 데이터
데이터전역/지역 변수, 자유롭게 변경객체 내부에 캡슐화, 메서드로 변경불변 데이터, 변경 시 복사
주요 개념함수, 프로시저클래스, 객체, 상속, 다형성순수 함수, 불변성, 고차 함수
초점어떻게(How) 할 것인가객체 간의 상호작용데이터 흐름과 변환

🤝 섞어 쓰면 더 강력해요! (멀티 패러다임 활용법)

"그래서 이 중에 하나만 골라 써야 하나요?" 라고 물으신다면, "아니요!" 라고 답해드릴게요. 요즘 개발 트렌드는 오히려 각 패러다임의 장점만 쏙쏙 뽑아서 섞어 쓰는 것이에요. 그리고 타입스크립트는 이런 멀티 패러다임 접근을 아주 잘 지원하는 멋진 언어랍니다!

어떻게 섞어 쓸 수 있을까요?

  • OOP + FP
    프로그램의 전체적인 뼈대는 클래스 기반의 객체(OOP)로 만들어서 구조를 잡고, 객체 안에서 복잡한 데이터를 처리하거나 계산하는 로직은 순수 함수(FP)를 활용해서 명확하고 테스트하기 쉽게 만들 수 있어요. React 컴포넌트에서 상태 관리는 Hooks(FP 영향)로, 컴포넌트 구조는 함수/클래스(OOP 느낌)로 짜는 걸 생각해보세요!

  • PP + OOP/FP
    프로그램 시작 부분의 설정이나 간단한 유틸리티 스크립트는 절차형으로 짜는 게 편할 수 있어요. 그러다가 재사용이 필요하거나 복잡해지는 부분은 객체나 순수 함수로 분리해서 관리하는 거죠.

타입스크립트로 섞어 쓰는 예시 (사용자 관리)

이번엔 사용자 정보를 관리하는 클래스(OOP) 안에 함수형 스타일(FP)을 녹여볼게요.

// 이메일 형식이 맞는지 검사하는 순수 함수 (FP 스타일)
function isValidEmailFormat(email: string): boolean {
  // 아주 간단한 이메일 정규식 (실제로는 더 복잡할 수 있어요!)
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

// 사용자 정보 타입 정의 (불변성을 위해 readonly 사용)
type UserProfile = {
  readonly userId: string;
  readonly name: string;
  readonly email: string;
  readonly isActive: boolean;
};

// 사용자 관리 클래스 (OOP 스타일)
class UserManager {
  // 사용자 목록을 Map으로 관리 (객체의 상태)
  private users: Map<string, UserProfile> = new Map();

  // 새 사용자 추가 (객체 상태 변경 메서드)
  addUser(name: string, email: string): string | null {
    // 유효성 검사는 순수 함수(FP)에 맡겨요!
    if (!isValidEmailFormat(email)) {
      console.error('🚨 잘못된 이메일 형식이에요!');
      return null;
    }

    const userId = `user_${Math.random().toString(36).substring(2, 9)}`; // 임시 ID 생성
    // 새로운 사용자 정보 객체 생성 (불변)
    const newUser: UserProfile = { userId, name, email, isActive: true };
    this.users.set(userId, newUser); // 상태 업데이트
    console.log(`✅ 사용자 추가 성공: ${name} (ID: ${userId})`);
    return userId;
  }

  // 사용자 비활성화 (상태 변경 메서드 + FP 원칙 적용)
  deactivateUser(userId: string): boolean {
    const user = this.users.get(userId);
    if (!user) {
      console.error('🤷 사용자를 찾을 수 없어요.');
      return false;
    }

    // 기존 사용자 정보를 복사하고 isActive만 false로 바꾼 '새로운' 객체 만들기 (불변성)
    const updatedUser: UserProfile = {
      ...user,
      isActive: false,
    };

    this.users.set(userId, updatedUser); // 업데이트된 정보로 상태 변경
    console.log(`🚫 사용자 비활성화: ${user.name}`);
    return true;
  }

  // 활성 사용자 이메일 목록 가져오기 (FP 고차 함수 활용)
  getActiveUserEmails(): string[] {
    // 1. Map의 값들(사용자 객체)을 배열로 변환
    // 2. filter로 활성 사용자(isActive가 true)만 거르고 (FP)
    // 3. map으로 이메일 주소만 뽑아내기 (FP)
    return Array.from(this.users.values())
      .filter(user => user.isActive)
      .map(user => user.email);
  }

  // 사용자 정보 조회 (객체 상태 접근)
  getUserInfo(userId: string): Readonly<UserProfile> | undefined {
    // 외부에서 실수로라도 수정 못하게 Readonly 타입으로 반환!
    return this.users.get(userId);
  }
}

// 사용 예시
const manager = new UserManager();
const userId1 = manager.addUser('김코딩', 'coding.kim@example.com');
const userId2 = manager.addUser('박프론트', 'frontend.park@example.com');
manager.addUser('이백엔드', 'backend@lee'); // 이메일 형식 오류!

if (userId1) {
  manager.deactivateUser(userId1); // 김코딩 비활성화
}

console.log('활성 사용자 이메일 목록:', manager.getActiveUserEmails()); // ['frontend.park@example.com']

const parkInfo = userId2 ? manager.getUserInfo(userId2) : undefined;
console.log('박프론트 정보:', parkInfo);

UserManager라는 클래스(OOP)가 전체적인 구조를 잡고 사용자 데이터를 관리하지만, 이메일 검증은 별도의 순수 함수(isValidEmailFormat)에 맡기고, 활성 사용자 이메일을 찾는 작업은 filtermap 같은 함수형 메서드(FP)를 사용했죠? 사용자 비활성화 시에도 기존 객체를 바꾸는 대신 새 객체를 만들어 불변성을 지키려고 했고요. 이렇게 섞어 쓰면 각 방식의 장점을 모두 누릴 수 있어요!

🤔 그래서 뭘 써야 하죠? (패러다임 선택 가이드)

결론적으로, "어떤 패러다임이 최고다!" 라고 말할 수는 없어요. 각각의 방식은 저마다의 장단점과 빛을 발하는 상황이 다르거든요.

  • 간단한 스크립트나 작은 작업에는 절차형이 빠르고 편할 수 있어요.

  • 복잡한 현실 세계를 모델링하거나 재사용 가능한 컴포넌트를 만들어야 한다면 객체 지향(OOP)이 좋은 선택지가 될 수 있어요.

  • 데이터의 흐름이 중요하고 예측 가능성, 테스트 용이성, 동시성이 중요하다면 함수형(FP) 접근 방식이 큰 도움이 될 거예요.

가장 중요한 것은 각 패러다임의 핵심 철학을 이해하고, 내가 해결하려는 문제의 성격, 프로젝트의 규모, 함께 일하는 팀의 경험 등을 종합적으로 고려해서 가장 적절한 방식을 선택하거나 현명하게 조합하는 유연성이에요.

타입스크립트처럼 여러 패러다임의 문을 열어주는 언어를 사용한다면, 이런 유연성을 발휘해서 더 효율적이고 관리하기 좋은 코드를 작성할 수 있답니다. 다양한 스타일을 맛보고 상황에 맞게 최고의 레시피를 만들어나가는 것, 이게 바로 현대 개발자의 중요한 역량 아닐까요? 😉

0개의 댓글