왜 이런 개념을 배워야할까?
개발을 하다 보면 단순히 코드를 작성하는 것뿐만 아니라, 코드를 수정하고 유지보수하는 과정이 점점 더 중요해진다는 것을 깨닫게 된다.
간단한 프로그램을 만들 때는 크게 상관없지만, 프로젝트가 커지고 팀 단위로 협업하게 되면 코드가 복잡해지고 수정이 어려워진다.
이때 객체 지향 프로그래밍(OOP)의 핵심 원칙과 패턴을 이해하고 활용하면 코드를 더 쉽게 이해하고, 수정할 때 최소한의 영향을 주면서도 확장 가능하게 만들 수 있다.
예를 들어, 단순히 extends 키워드를 사용해서 상속을 적용하는 것만으로는 좋은 OOP 설계를 했다고 할 수 없다. 상속을 왜 해야 하는지, 언제 하면 안 되는지, 그리고 더 좋은 방법은 무엇인지 고민해야 한다.
이 글에서는 OOP의 주요 가치(커뮤니케이션, 단순성, 유연성)와 원칙(지역적 변화, 최소 중복, 로직과 데이터 결합, 대칭성, 선언적 표현, 변화율)을 이해하고, 이를 코드에서 어떻게 구현할 수 있는지 예제와 함께 설명할 것이다.
패턴이란 무엇인가?
프로그래밍이란 무엇인가? 왜 변수가 존재하는가? 메서드는? 상속 관계는? 접근 제한자는? 클래스의 개념과 인스턴스의 개념은 왜 탄생했는가?
사실 객체지향뿐만 아니라 여러 디자인 패턴을 이해하려면 위의 의문이 와닿아야 한다. 우리는 왜 시스템을 더 복잡하게 만드는가?
켄트 백의 말을 빌리면
- 프로그램은 새로 짜는 것보다 기존의 것을 읽는 게 더 많다.
- 프로그램은 완성되지 않는다. 계속된 수정만이 있다.
- 수많은 상태와 흐름이 존재하고 이를 프로그램으로 명시하고 제어해야 한다.
- 함께 일하고, 함께 일할 동료들이 개념을 이해하고 세부 사항에 대해서 이해해야 한다.
- 세부 사항을 이해해야 전체 그림이 보이고, 전체 그림이 보여야 세부 사항을 이해할 수 있다.
이미 많은 사람들이 경험으로 축적한 디자인 패턴들은 이런 과정을 더 빠르고 효율적으로 만들어준다.
적절한 패턴 사용은 불필요한 재개발 비용을 줄이고, 새로운 기능에 집중할 시간을 벌어준다.
패턴을 통해 빠르고 저렴하게 문제 해결이 가능하며, 실질적으로 시간이 필요한 기능을 개발할 시간을 확보할 수 있다.
프로그래밍 이론
완벽한 패턴은 없다.
담백한 후라이드와 매콤달콤한 양념치킨처럼 모든 상황을 커버하는 패턴은 존재하지 않는다.
상황마다 요구사항과 제약이 다르기 때문에, ‘최적’이라 여겨지는 패턴도 다른 맥락에선 적합하지 않을 수 있다.
결국 우리가 고려해야 할 핵심 기준은 가치(value)와 원칙(principle)이다.
과연 어떤 것이 가치 있는 프로그램일까?
말 그대로 코드가, 패턴이 가질수 있고 보여지고 있는 가치이다.
1. 커뮤니케이션
코드가 명확하다면, 개발자가 빠르게 이해하고 수정할 수 있다.
이는 곧 시간·비용 절감으로 이어진다.
- 주석, 함수명, 구조를 통해 의도를 분명히 드러내고, 복잡도를 줄여야 한다.
- 코드가 팀원과 소통하듯, 의도를 선명히 전하는 것이 중요하다.
- 예: int cost = 0; → int의 초기값이 0임을 알면서도 선언하는 이유는 가독성 때문이다.
잘못된 예시 (가독성이 떨어지는 코드)
public class User {
int a;
boolean b;
public User(int a, boolean b) {
this.a = a;
this.b = b;
}
}
> a와 b가 무엇을 의미하는지 전혀 알 수 없다. 이런 코드는 협업 시 가독성을 떨어뜨린다.
개선된 예시 (명확한 네이밍과 의미 부여)
public class User {
private int age;
private boolean isActive;
public User(int age, boolean isActive) {
this.age = age;
this.isActive = isActive;
}
}
의미를 알 수 있도록 필드명을 변경했다.
> 이제 이 클래스를 처음 보는 사람도 User 객체가 age와 isActive 상태를 가진다는 것을 이해할 수 있다.
2. 단순성
- 복잡도를 낮추면 유지보수가 쉬워진다.
- 불필요한 코드와 과도한 설계를 제거하라.
- 단순한 구조는 오류 가능성을 줄이고, 변경 시 다른 부분에 미치는 영향을 최소화한다.
잘못된 예시 (과도한 설계)
public abstract class Animal {
abstract void makeSound();
}
public class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Bark");
}
}
public class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Meow");
}
}
이 코드에서는 makeSound()를 위한 상속이 과도하게 사용되었다.
> 단순히 메서드 하나만 다르게 동작하는 경우라면 굳이 상속을 사용할 필요가 없다.
개선된 예시 (단순한 구조로 변경)
public class Animal {
private String sound;
public Animal(String sound) {
this.sound = sound;
}
public void makeSound() {
System.out.println(sound);
}
}
// 사용 예시
Animal dog = new Animal("Bark");
Animal cat = new Animal("Meow");
dog.makeSound();
cat.makeSound();
단순히 sound 속성만 다를 경우, 상속을 사용할 필요 없이 하나의 클래스로 표현할 수 있다.
> 불필요한 클래스를 줄여 코드의 복잡도를 낮췄다.
3. 유연성
- 수정이 쉽게 이루어질 수 있도록 구조를 유연하게 설계하는 것이 중요하다.
- 필요 이상으로 확장에 대비해 복잡도를 높이는 것은 피해야 한다.
- 당장 유용하고, 미래에 발전 가능성이 높은 부분부터 유연성을 적용해야 한다.
- 실제 운영 경험을 쌓아야 어떤 서비스인지, 어떤 클라이언트와 함께 작업하는지에 따라서 설계의 방향이 명확해진다.
잘못된 예시 (변경에 취약한 코드)
public class PaymentProcessor {
public void processCreditCardPayment() {
System.out.println("Processing credit card payment");
}
public void processPaypalPayment() {
System.out.println("Processing PayPal payment");
}
}
> 새로운 결제 방식이 추가될 때마다 새로운 메서드를 만들어야 하므로 확장성이 떨어진다.
*개선된 예시 (전략 패턴 적용으로 유연성 증가)
interface PaymentStrategy {
void pay();
}
class CreditCardPayment implements PaymentStrategy {
@Override
public void pay() {
System.out.println("Processing credit card payment");
}
}
class PaypalPayment implements PaymentStrategy {
@Override
public void pay() {
System.out.println("Processing PayPal payment");
}
}
class PaymentProcessor {
private PaymentStrategy strategy;
public PaymentProcessor(PaymentStrategy strategy) {
this.strategy = strategy;
}
public void processPayment() {
strategy.pay();
}
}
// 사용 예시
PaymentProcessor processor = new PaymentProcessor(new CreditCardPayment());
processor.processPayment();
새로운 결제 방식이 추가될 때 기존 코드를 수정할 필요 없이 새로운 클래스를 추가하면 된다.
> OCP(개방-폐쇄 원칙, Open-Closed Principle)를 준수하여 확장에는 열려 있고, 변경에는 닫혀 있는 구조를 만든다.
원칙: 어떤 원칙을 지켜야 할까?
패턴이 생기고 정립된 데에는 이유가 있다.
위에서 설명한 가치(value) 중 하나 이상을 확실하게 내포하고 있다.
대부분 코드 수정 비용을 낮추고 가치를 극대화하는 목적을 갖는다.
1. 지역적 변화 (Local Consequences)
- 핵심 요점: 수정 범위를 최소화하라.
- 프로그램 일부만 고쳐도 여러 곳이 연쇄적으로 깨진다면, 유지보수 비용이 기하급수적으로 올라간다.
- 관심사가 명확히 분리된 구조는 ‘지역적’ 변화가 가능하게 하며, 개발자 간 커뮤니케이션도 쉬워진다.
소프트웨어는 변경될 수밖에 없다. 그러나 변경이 필요할 때 한 곳을 수정하면 연쇄적으로 다른 여러 부분을 수정해야 한다면 유지보수 비용이 기하급수적으로 증가한다.
따라서, 코드 구조를 설계할 때 변경이 필요한 범위를 최소화하고, 다른 코드에 미치는 영향을 줄이는 것이 중요하다.
잘못된 예시 (변경이 여러 곳에 영향을 주는 코드)
예를 들어, 직원(Employee)의 급여를 계산하는 시스템이 있다고 가정하자.
public class Employee {
private String name;
private double baseSalary;
public Employee(String name, double baseSalary) {
this.name = name;
this.baseSalary = baseSalary;
}
public double calculateSalary(String type) {
if (type.equals("Manager")) {
return baseSalary * 1.2; // 관리자 보너스
} else if (type.equals("Engineer")) {
return baseSalary * 1.1; // 엔지니어 보너스
} else {
return baseSalary; // 기본 급여
}
}
}
> 새로운 직원 유형이 추가되면 calculateSalary() 메서드를 수정해야 한다.
calculateSalary()를 수정할 때 기존의 로직을 건드려야 하므로, 사이드 이펙트(부작용)가 발생할 가능성이 높다.
급여 계산 로직이 Employee 클래스 내부에 있으므로, 급여 정책이 변경되면 모든 Employee 객체에 영향을 미친다.
개선된 예시 (변경 범위를 최소화한 코드 - 개방/폐쇄 원칙 적용)
이제 변경이 필요한 범위를 한 곳으로 모으고, 기존의 코드를 수정하지 않고 새로운 기능을 추가할 수 있도록 설계해 보자.
interface SalaryCalculator {
double calculateSalary(double baseSalary);
}
class ManagerSalary implements SalaryCalculator {
@Override
public double calculateSalary(double baseSalary) {
return baseSalary * 1.2;
}
}
class EngineerSalary implements SalaryCalculator {
@Override
public double calculateSalary(double baseSalary) {
return baseSalary * 1.1;
}
}
class DefaultSalary implements SalaryCalculator {
@Override
public double calculateSalary(double baseSalary) {
return baseSalary;
}
}
public class Employee {
private String name;
private double baseSalary;
private SalaryCalculator salaryCalculator;
public Employee(String name, double baseSalary, SalaryCalculator salaryCalculator) {
this.name = name;
this.baseSalary = baseSalary;
this.salaryCalculator = salaryCalculator;
}
public double calculateSalary() {
return salaryCalculator.calculateSalary(baseSalary);
}
}
> 새로운 직원 유형이 추가되더라도 기존 Employee 클래스를 수정할 필요가 없다.
- 예를 들어 InternSalary 클래스를 추가하면, Employee 클래스는 그대로 유지된다.
- 즉, 변경이 필요한 범위를 SalaryCalculator 인터페이스 구현체로 한정했다.
> 급여 정책이 변경되더라도 기존 직원 객체(Employee)에는 영향이 없다.
- 객체 간 책임이 분리되어 있어 유지보수가 쉬워진다.
항상 생각해야되는 케이스이기 때문에 또 다른 예제도 확인해보자.
잘못된 예시 2 (변경이 여러 곳에 영향을 주는 코드)
public class Product {
private String name;
private double price;
private String currency; // USD, EUR, KRW
public Product(String name, double price, String currency) {
this.name = name;
this.price = price;
this.currency = currency;
}
public double getPriceInUSD() {
if (currency.equals("EUR")) {
return price * 1.1; // 유로 -> 달러 환율
} else if (currency.equals("KRW")) {
return price * 0.0008; // 원 -> 달러 환율
} else {
return price; // 이미 달러라면 변환 없음
}
}
}
새로운 통화가 추가되면 getPriceInUSD() 메서드를 수정해야 한다.
환율 변경 시 Product 클래스 자체를 수정해야 하므로 수정 범위가 커진다.
> 통화 변환 로직이 Product와 강하게 결합되어 있어 변경에 취약하다.
개선된 예시 2 (변경 범위를 최소화한 코드 - 개방/폐쇄 원칙 적용)
interface CurrencyConverter {
double convertToUSD(double amount);
}
class KRWConverter implements CurrencyConverter {
@Override
public double convertToUSD(double amount) {
return amount * 0.0008;
}
}
class EURConverter implements CurrencyConverter {
@Override
public double convertToUSD(double amount) {
return amount * 1.1;
}
}
class DefaultConverter implements CurrencyConverter {
@Override
public double convertToUSD(double amount) {
return amount; // USD 그대로 유지
}
}
public class Product {
private String name;
private double price;
private CurrencyConverter currencyConverter;
public Product(String name, double price, CurrencyConverter currencyConverter) {
this.name = name;
this.price = price;
this.currencyConverter = currencyConverter;
}
public double getPriceInUSD() {
return currencyConverter.convertToUSD(price);
}
}
새로운 통화가 추가되더라도 기존 Product 클래스를 수정할 필요가 없다.
- 예를 들어 JPYConverter 클래스를 추가하면 Product는 수정 없이 사용할 수 있다.
> 환율이 바뀌어도 CurrencyConverter 인터페이스 구현체만 변경하면 된다.
> Product는 가격과 제품 정보만 관리하며, 통화 변환 로직을 분리하여 관심사 분리를 실현했다.
변경이 필요한 범위를 최소화하면, 사이드 이펙트(부작용) 없이 기존 코드의 안정성을 유지할 수 있다.
OCP(개방-폐쇄 원칙, Open-Closed Principle)를 적용하면, 확장에는 열려 있고, 변경에는 닫혀 있는 구조가 된다.
관심사를 분리하여 변경이 필요한 부분만 수정하면 되도록 설계하면 유지보수가 훨씬 쉬워진다.
지역적 변화를 최소화하는 것은 단순한 설계 기법이 아니라, 코드 유지보수성을 극대화하는 핵심 원칙이다.이 원칙을 잘 이해하고 적용하면, 변경이 필요할 때마다 기존 코드를 수정하지 않고도 확장할 수 있는 유연한 시스템을 만들 수 있다.
2. 최소 중복 (Minimize Duplication)
- 핵심 요점: 중복 코드는 수정 비용을 높인다.
- 프로그램을 작은 단위(짧은 메서드, 작은 객체, 모듈 등)로 나누면 중복을 찾고 제거하기 쉬워진다.
잘못된 예시 (중복된 코드 사용)
public class UserService {
public void createUser(String name) {
System.out.println("User " + name + " created.");
}
public void updateUser(String name) {
System.out.println("User " + name + " updated.");
}
}
> User 관련 로직이 중복되어 있다.
개선된 예시 (중복 제거)
public class UserService {
private void logUserAction(String action, String name) {
System.out.println("User " + name + " " + action + ".");
}
public void createUser(String name) {
logUserAction("created", name);
}
public void updateUser(String name) {
logUserAction("updated", name);
}
}
> 공통 로직을 logUserAction 메서드로 분리하여 중복을 제거했다.
3. 로직과 데이터의 결합 (Keep Logic and Data Together)
- 핵심 요점: 함께 변하는 로직과 데이터는 가까운 범위에 둬라.
- 관련된 데이터와 로직이 서로 떨어져 있으면, 변경 시 양쪽을 수정해야 하므로 부담이 커진다.
- 같은 객체나 패키지 내에서 로직과 데이터를 밀접하게 유지하면 수정 비용이 줄어든다.
로직과 데이터가 분리되어 있으면, 변경 시 여러 곳을 수정해야 하고, 코드 이해가 어려워진다.
예를 들어, 학생 정보를 관리하는 시스템에서 학생의 성적을 평가하는 로직이 별도의 유틸리티 클래스에 존재한다고 해보자.
잘못된 예시 (성적 평가와 관리가 동시에 존재)
public class Student {
private String name;
private int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
public int getScore() {
return score;
}
}
public class GradeUtil {
public static String getGrade(int score) {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
return "F";
}
}
> 학생이 성적을 평가하는 방법이 바뀌면 GradeUtil과 Student 클래스를 함께 수정해야 한다.
로직과 데이터를 함께 묶어 Student 클래스 내에서 관리하면 유지보수가 훨씬 용이하다.
개선된 예시 (로직과 데이터를 분리)
public class Student {
private String name;
private int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
public String getGrade() {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
return "F";
}
}
> 이제 성적 평가 방식이 변경되더라도 Student 클래스만 수정
4. 대칭성 (Symmetry)
- 핵심 요점: 한 객체 안의 필드들은 함께 생성되고 함께 소멸된다.
- 대칭적인 구조를 고려하면 코드가 왜 그렇게 작성되었는지 이해하기 쉬워진다.
- 예: count++ 대신 incrementCount() 사용 → 의도가 더 명확해진다.
코드를 작성할 때 객체의 생성과 소멸이 비대칭적으로 이루어지면 메모리 누수나 예측하기 어려운 버그가 발생할 수 있다.
예를 들어, 데이터베이스 연결을 관리하는 코드에서 객체가 명확하게 해제되지 않는 경우를 생각해보자.
잘못된 예시 (연결에 대한 메서드만 구현)
public class DatabaseConnection {
private Connection connection;
public DatabaseConnection() throws SQLException {
this.connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "user", "password");
}
public Connection getConnection() {
return connection;
}
}
> 이 경우, DatabaseConnection 객체를 생성한 후, 연결을 닫지 않으면 계속 유지되어 메모리 누수가 발생할 수 있다.
이를 해결하기 위해서는 생성과 소멸을 대칭적으로 유지해야 한다.
개선된 예시 (생성과 소멸을 대칭적으로 유지)
public class DatabaseConnection implements AutoCloseable {
private Connection connection;
public DatabaseConnection() throws SQLException {
this.connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "user", "password");
}
public Connection getConnection() {
return connection;
}
@Override
public void close() throws SQLException {
if (connection != null) {
connection.close();
}
}
}
// 사용 예시
try (DatabaseConnection dbConn = new DatabaseConnection()) {
Connection conn = dbConn.getConnection();
// 데이터베이스 작업 수행
} catch (SQLException e) {
e.printStackTrace();
}
> 이제 try-with-resources를 사용하여 DatabaseConnection 객체가 자동으로 닫히도록 보장할 수 있다.
5. 선언적 표현 (Declarative Expression)
- 핵심 요점: 순서가 중요하지 않을 땐 선언형이 이해하기 쉽다.
- 명령형 코드는 유연성이 높지만, 제어 흐름을 따라가며 상태 변화를 추적해야 한다.
- 예: JUnit 4의 @RunWith(Suite.class) → 무엇을 하는지 직관적으로 파악 가능하다.
절차지향적인 코드(명령형 프로그래밍)는 동작을 세세하게 기술하지만, 선언적 코드는 "무엇을 해야 하는지"에 집중하여 가독성을 높인다.
예를 들어, 리스트에서 짝수를 찾아 제곱한 후 출력하는 로직을 명령형 방식으로 작성하면 다음과 같다.
잘못된 예시 (명령형 방식으로 짜여진 코드)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> result = new ArrayList<>();
for (int number : numbers) {
if (number % 2 == 0) {
result.add(number * number);
}
}
for (int num : result) {
System.out.println(num);
}
> 반복문과 조건문을 따라가며 로직을 해석해야 하므로 가독성이 떨어진다.
같은 기능을 선언형 방식으로 개선하면 다음과 같다.
개선된 예시 (의도를 코드에 녹여낸 선언적 방식)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.forEach(System.out::println);
> "짝수를 필터링하고(map), 제곱한 후(forEach) 출력한다"는 의도가 즉시 파악 가능하다.
따라서 Collection이 일반for 문보다 성능이 안좋지만 실무에서 사용되는 이유도 선언적 방식으로 작성이 가능하기에 해당 메서드를 작성하는 의도가 보여진다 라는 장점에 의해 사용되는것이다.
6. 변화율 (Rate of Change)
- 핵심 요점: 함께 변하는 것끼리 묶고, 다른 주기로 변하는 것들은 분리하라.
- 코드나 데이터가 변할 때마다 엮인 부분 전체를 고쳐야 한다면 유지보수성이 크게 떨어진다.
- 불필요한 필드는 지역 변수로 내리거나, 여러 파라미터 대신 Money 객체처럼 의미 단위로 합치는 방식을 사용하면 수정이 간단해진다.
변화율을 고려하지 않으면 코드 수정이 있을 때 불필요한 부분까지 수정해야 하고, 유지보수 비용이 증가한다. 함께 변하는 것들은 한 곳에 모으고, 서로 다른 주기로 변하는 것들은 분리해야 한다.
예를 들어, 쇼핑몰에서 배송비 계산 방식이 자주 바뀌지만, 주문 정보는 거의 변하지 않는다고 가정해 보자.
변화를 고려하지 않고 코드를 작성하면 다음과 같다
잘못된 예시 (변화주기를 고려하지 않은 코드)
public class Order {
private double totalPrice;
public Order(double totalPrice) {
this.totalPrice = totalPrice;
}
public double calculateFinalPrice(String shippingMethod) {
double shippingCost = 0;
if (shippingMethod.equals("standard")) {
shippingCost = 5.0;
} else if (shippingMethod.equals("express")) {
shippingCost = 10.0;
}
return totalPrice + shippingCost;
}
}
> 이 코드에서 배송비 정책이 바뀔 때마다 Order 클래스를 수정해야 하는 문제가 발생한다.
하지만 Order는 주문과 관련된 로직만을 다뤄야 하며, 배송비 정책은 별도의 개념이므로 따로 분리하는 것이 바람직하다.
변화율을 고려하여 리팩토링하면 다음과 같이 개선할 수 있다.
개선된 예시 (변화율을 고려한 분리- 배송비 정책과 주문 정보의 분리)
interface ShippingStrategy {
double calculateShippingCost();
}
class StandardShipping implements ShippingStrategy {
@Override
public double calculateShippingCost() {
return 5.0;
}
}
class ExpressShipping implements ShippingStrategy {
@Override
public double calculateShippingCost() {
return 10.0;
}
}
public class Order {
private double totalPrice;
private ShippingStrategy shippingStrategy;
public Order(double totalPrice, ShippingStrategy shippingStrategy) {
this.totalPrice = totalPrice;
this.shippingStrategy = shippingStrategy;
}
public double calculateFinalPrice() {
return totalPrice + shippingStrategy.calculateShippingCost();
}
}
이제 배송비 정책이 바뀌어도 ShippingStrategy만 수정하면 되고, Order 클래스는 그대로 유지된다.
> 즉, 주문 정보와 배송비 정책의 변화율이 다르므로 이를 분리하여 유지보수를 쉽게 만든다.
마무리
이 글에서 다룬 OOP 원칙과 예제들은 단순히 이론이 아니라
실제 프로젝트에서 코드를 더 읽기 쉽게 만들고 유지보수성을 높이기 위한 필수적인 개념이다.
처음에는 이 원칙들이 다소 추상적으로 느껴질 수 있지만, 코드를 작성하고 리뷰받는 과정을 거치면서 점점 더 필요성을 체감하게 될 것이다.
좋은 코드는 혼자 읽을 때뿐만 아니라, 팀원과 함께 협업할 때도 이해하기 쉬운 코드이다.
이 원칙들을 염두에 두고 개발하면, 더 나은 코드 품질을 유지하면서도 성장할 수 있을 것이다
'CS' 카테고리의 다른 글
예제로 배우는 싱글턴 패턴; 싱글턴은 왜 배울까? (0) | 2025.01.31 |
---|---|
CORS 에러 발생 시나리오 정리 (0) | 2024.08.21 |
DNS, TCP, TLS 통신에 대한 WireShark를 이용한 패킷 분석 (1) | 2024.06.30 |