객체지향 설계의 원칙인 SOLID는 단순한 개념 같으면서도 처음 접하면 다소 어렵게 느껴집니다. 특히, 객체지향 설계를 도와주는 프레임워크인 스프링을 배우면서 "왜 굳이 이렇게 해야 할까?"라는 의문이 들기도 합니다. 하지만 여러 번의 개발 경험과 학습을 통해 그 원리와 필요성을 점차 이해하게 되고, 이를 적용했을 때 얻을 수 있는 이점을 자연스럽게 체감하게 되는 것 같습니다.
"SOLID 원칙"은 객체지향 프로그래밍의 가이드라인이라고 할 수 있습니다. SOLID는 코드의 유지보수성, 확장성, 가독성을 모두 높이는 데 중점을 두고 있습니다. 하지만 이 원칙은 절대적인 것이 아닙니다. 따라서 복잡한 비즈니스 로직이나 스파게티 코드가 아닌 이상, 이 원칙에 지나치게 집착할 필요는 없다고 생각합니다.
1. SRP(Single Responsibility Principle): 단일 책임 원칙
단일 책임 원칙이란 클래스는 단 하나의 책임만 가져야 하며, 변경의 이유도 하나여야 하는 원칙을 말합니다.
하나의 클래스는 오직 하나의 책임(일)만을 가지며, 변경의 이유 또한 그 책임에만 국한되어야 한다는 것입니다. 만약에 하나의 클래스가 여러 가지 일을 맡고 그에 따라 변경 이유도 다양하다면, 코드가 복잡해지고 관리와 유지보수가 어려워질 수 있습니다.
다음은 로그인 예시로 "사용자 인증 -> 이메일 알림 -> 로그인 기록"을 하는 코드입니다.
public class UserLoginService {
public void loginUser(User user) {
boolean authenticated = authenticateUser(User user);
if (authenticated) {
sendEmail(user);
logLogin(user);
}
}
private boolean authenticateUser(User user) {
// 사용자 인증
}
private void sendEmail(User user) {
//로그인 이메일 발송
}
private void logLogin(User user) {
// 로그인 로그 기록
}
}
UserLoginService 클래스는 사용자 인증뿐만 아니라 이메일 발송, 로그인 기록도 같이 처리하고 있습니다. 인증, 이메일 발송, 기록은 서로 다른 책임이라고 볼 수 있으므로 이를 분리시킬 필요가 있습니다.
다음은 이를 개선한 코드입니다.
// 사용자 인증 클래스
public class UserAuthenticator {
public boolean authenticate(User user) {
// 사용자 인증 로직
}
}
}
//이메일 전송 클래스
public class Sender {
public void sendEmail(User user) {
// 이메일 전송 로직
}
}
//로그 기록 클래스
public class Logger {
public void log(User user) {
// 로그 기록
}
}
// 사용자 로그인 서비스 클래스
public class UserLoginService {
private final UserAuthenticator authenticator;
private final Sender sender;
private final Logger logger;
public UserLoginService(UserAuthenticator authenticator, Sender sender, Logger logger) {
this.authenticator = authenticator;
this.sender = sender;
this.logger = logger;
}
public void loginUser(User user) {
boolean authenticated = authenticator.authenticate(User user);
if (authenticated) {
sender.sendEmail(user);
logger.log(user);
}
}
}
개선된 설계의 장점
- 책임 분리: UserAuthenticator는 오직 사용자 인증만 담당하고, Logger는 기록, Sender는 전송의 역할만 합니다. 각 클래스는 하나의 명확한 책임을 가집니다.
- 응집력 향상: 각 클래스는 관련된 기능만 처리하므로 응집력이 높아지고, 코드가 더 명확하고 유지보수가 쉬워집니다.
- 유지보수 용이: 로그인 인증 로직을 수정하거나, 로그인 기록 방식을 변경할 때 각각의 클래스를 독립적으로 수정할 수 있습니다. 예를 들어, 로그 기록 방식을 변경하더라도 UserLoginService 클래스는 영향을 받지 않습니다.
SRP에 대해 간단하게 알아보았습니다. 하지만 SRP를 적용하는 과정에서 주의해야 할 몇 가지 문제와 어려움이 있습니다.
1. 책임의 정의가 주관적임
SRP를 제대로 구현하려면 각 클래스의 책임이 무엇인지 명확하게 정의해야 합니다. 하지만 클래스의 책임이 무엇인지를 정의하는 것은 사람마다 주관적입니다. 따라서 같은 요구사항을 두고도 여러 개발자가 각기 다른 방식으로 책임을 정의할 수 있습니다. 이럴 경우, 팀 내에서 충분히 논의하고 협의를 통해 책임의 범위를 정의하는 것이 중요할 것입니다.
2. 클래스 설계에서의 과도한 분리
클래스 설계에서 과도한 분리는 문제가 될 수 있습니다. SRP를 적용하려는 과정에서 각 메서드를 지나치게 분리하면, 실제로 밀접하게 연관된 기능들이 복잡하게 나누어져 코드 관리가 힘들어질 수 있습니다. 예를 들어, 텍스트 작성, 수정, 삭제 기능을 각각 다른 클래스에 분리하면, 이들 간의 관계가 복잡해져 코드가 불필요하게 분산될 수 있습니다.
따라서, 적절한 응집력과 책임 분리 사이의 균형을 맞추는 것이 중요합니다. 텍스트 조작과 관련된 메서드들은 "텍스트를 조작한다"는 공통된 책임 아래 함께 묶어두는 것이 더 효율적일 수 있습니다.
2. OCP(Open-Closed Principle): 개방-폐쇄 원칙
개방-폐쇄 원칙은 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있도록 설계하자는 뜻입니다.
OCP의 핵심은 새로운 요구사항이 추가될 때 기존 코드를 수정하지 않고, 새로운 코드를 추가하여 확장할 수 있어야 한다는 것입니다. 즉, 시스템은 확장 가능해야 하며, 기존 클래스나 모듈은 새로운 기능 추가로 인해 수정되지 않아야 합니다. 이를 통해 기존 코드의 안정성을 유지하면서도 새로운 기능을 유연하게 추가할 수 있습니다.
다음은 각 할인 유형에 따라 할인 금액을 계산하는 코드입니다.
class DiscountCalculator {
public double calculate(double price, String discountType) {
if (discountType.equals("percentage")) {
return price * 0.9; // 10% 할인
} else if (discountType.equals("flat")) {
return price - 20; // 20원 할인
} else {
return price; // 할인 없음
}
}
}
위 코드에서 새로운 할인 유형을 추가하려면 DiscountCalculator 클래스의 코드 자체를 수정해야 하므로, OCP를 준수하지 않습니다. 새로운 요구사항이 생길 때마다 기존 코드에 'else if'를 추가 하면서 수정해야 하므로 유지보수나 확장성 측면에서 불편이 따릅니다.
자바에서 Interface는 OCP를 지키기 위한 방법 중 하나입니다. 다음은 이를 개선된 코드입니다.
// 할인 전략을 정의하는 인터페이스
interface DiscountStrategy {
double calculateDiscount(double price);
}
// Percentage 할인 전략
class PercentageDiscount implements DiscountStrategy {
public double calculateDiscount(double price) {
return price * 0.9; // 10% 할인
}
}
// Flat 할인 전략
class FlatDiscount implements DiscountStrategy {
public double calculateDiscount(double price) {
return price - 20; // 20원 할인
}
}
// DiscountCalculator 클래스는 변경 없이 새로운 할인 전략을 추가할 수 있음
class DiscountCalculator {
private DiscountStrategy strategy;
public DiscountCalculator(DiscountStrategy strategy) {
this.strategy = strategy;
}
public double calculate(double price) {
return strategy.calculateDiscount(price);
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
DiscountStrategy percentageDiscount = new PercentageDiscount();
DiscountCalculator calculator1 = new DiscountCalculator(percentageDiscount);
double priceWithPercentageDiscount = calculator1.calculate(100);
DiscountStrategy flatDiscount = new FlatDiscount();
DiscountCalculator calculator2 = new DiscountCalculator(flatDiscount);
double priceWithFlatDiscount = calculator2.calculate(100);
}
}
DiscountStrategy는 인터페이스로 정의되어 있습니다. 각 할인 전략(PercentageDiscount, FlatDiscount)은 이 인터페이스를 구현합니다. 인터페이스를 사용하면 새로운 할인 전략을 추가할 때마다 새로운 클래스를 만들기만 하면 되며, DiscountCalculator 를 수정할 필요가 없고 이를 통해 OCP 원칙을 준수할 수 있습니다.
예시가 간단하기 때문에 수정 전의 코드가 더 간단합니다. 하지만 앞에서 말 했듯이 복잡한 비즈니스 로직이나 다양한 클래스 간의 관계가 있는 대규모 애플리케이션에서는 기존 코드를 수정하지 않고 확장하는 것이 중요합니다.
기존 코드를 수정하지 않고 확장하는 이유는, 수정이 기존의 기능에 예기치 않은 영향을 미칠 수 있기 때문입니다. 확장을 통해 새로운 기능을 추가하면 기존의 안정성을 유지할 수 있고, 시스템이 점진적으로 확장되어도 기존 동작을 보장할 수 있습니다.
3. LSP(Liskov Substitution Principle): 리스코프 치환 원칙
리스코프 치환 원칙이란 서브타입은 그 부모 타입으로 교체할 수 있어야 한다는 원칙으로, 다형성과 관련된 개념입니다.
즉, 부모 클래스의 객체를 자식 클래스의 객체로 대체해도 프로그램의 동작이 정상적으로 작동해야 한다는 것입니다. 이를 통해 상속 관계에서 자식 클래스가 부모 클래스의 계약을 위반하지 않도록 보장할 수 있습니다.
LSP를 지키는 이유는 다음과 같습니다.
- 상위 타입의 코드에서 하위 타입을 사용할 때 예기치 않은 동작이나 오류를 방지합니다.
- 유지보수와 확장성을 높이고, 객체지향 설계의 핵심인 재사용성과 다형성을 강화합니다.
다음은 LSP를 적용한 예시입니다.
// 상위 클래스: Bird (새)
class Bird {
public String fly() {
return "I can fly";
}
}
// 하위 클래스: Sparrow (참새)
class Sparrow extends Bird {
@Override
public String fly() {
return "Sparrow flying";
}
}
// 상위 타입을 사용하는 메서드
public class BirdExample {
public static void letBirdFly(Bird bird) {
System.out.println(bird.fly());
}
public static void main(String[] args) {
Bird bird = new Bird();
Sparrow sparrow = new Sparrow();
letBirdFly(bird); // 출력: I can fly
letBirdFly(sparrow); // 출력: Sparrow flying
}
}
Sparrow 클래스는 Bird 클래스의 fly 메서드를 재정의하면서도 상위 클래스의 계약(날 수 있는 능력)을 준수합니다.
따라서 letBirdFly 메서드에서 Bird를 Sparrow로 대체해도 정상적으로 작동합니다.
4. ISP(Interface Segregation Principle): 인터페이스 분리 원칙
인터페이스 분리 원칙이란 인터페이스를 설계할 때, 불필요한 기능은 제거하여 최소한의 기능만 유지 하는 것을 말합니다. 한 마디로, 필요한 기능만 정의하라는 뜻입니다.
주요 개념을 정리하면 다음과 같습니다.
- 작고 구체적인 인터페이스
하나의 큰 인터페이스를 여러 개의 작은 인터페이스로 나누어, 각 인터페이스가 클라이언트의 요구사항에 맞게 설계되도록 합니다. - 불필요한 의존성 제거
클라이언트가 필요하지 않은 기능에 의존하게 되면 유지보수성이 떨어지고, 불필요한 변경의 영향을 받을 수 있습니다. - SRP(Single Responsibility Principle)와의 연관성:
ISP는 인터페이스 수준에서 SRP를 적용하는 것과 유사하며, 각 인터페이스가 단일 책임을 가지도록 설계해야 합니다.
다음은 ISP를 위반한 간단한 예시입니다.
// 모든 동물 행동을 정의한 거대한 인터페이스
interface Animal {
void eat();
void fly(); // 모든 동물이 날 수 있는 것은 아님
void swim(); // 모든 동물이 수영할 수 있는 것도 아님
}
// 육상 동물: 사용하지 않는 메서드도 구현해야 함
class Dog implements Animal {
@Override
public void eat() {
System.out.println("Dog is eating.");
}
@Override
public void fly() {
// 강아지는 날 수 없으므로 빈 메서드
throw new UnsupportedOperationException("Dog can't fly.");
}
@Override
public void swim() {
System.out.println("Dog is swimming.");
}
}
Dog 클래스는 fly 메서드를 사용할 수 없지만, 인터페이스에 포함된 메서드이므로 구현해야 합니다. 이러한 설계는 클라이언트에 불필요한 의존성을 강요합니다.
따라서 다음과 같이 인터페이스를 분리하고, 필요한 것만 골라서 구현하도록 합니다.
// 동물의 기본 인터페이스
interface Animal {
void eat();
}
// 날 수 있는 동물의 인터페이스
interface Flyable {
void fly();
}
// 수영할 수 있는 동물의 인터페이스
interface Swimmable {
void swim();
}
// 육상 동물(필요한 기능만 구현)
class Dog implements Animal, Swimmable {
@Override
public void eat() {
System.out.println("Dog is eating.");
}
@Override
public void swim() {
System.out.println("Dog is swimming.");
}
}
// 날 수 있는 동물
class Bird implements Animal, Flyable {
@Override
public void eat() {
System.out.println("Bird is eating.");
}
@Override
public void fly() {
System.out.println("Bird is flying.");
}
}
5. DIP(Dependency Inversion Principle): 의존관계 역전 원칙
의존관계 역전 원칙이랑 구체적인 구현이 아닌 추상화(인터페이스나 추상 클래스)에 의존하도록 설계하는 것을 말합니다.
의존관계 역전 원칙(Dependency Inversion Principle, DIP)의 목적은 소프트웨어 설계의 결합도를 낮추는 것입니다. 일반적으로, 소프트웨어 설계에서 모듈 간의 직접적인 의존은 유지보수와 확장을 어렵게 만듭니다. 왜냐하면 모듈이 변경될 경우, 해당 모듈에 의존한 모든 곳에서 이를 반영해야 하기 때문입니다. 이는 설계의 유연성을 저해시킵니다.
따라서, 의존관계를 설정할 때는 구현체보다는 역할을 정의한 인터페이스에 의존하는 것이 좋습니다.
다음은 DIP를 위반한 예시 코드입니다.
// 추상화
interface Repository {
save();
}
// 구체적인 구현: MySQLRepository
class MySQLRepository implements Repository {
@Override
public void save(String data) {
//mysql 로직
}
}
class DataService {
private final Repository repository;
public DataService() {
this.repository = new MySQLRepository(); // 구체적인 구현에 직접 의존
}
public void save(String data) {
repository.saveData(data);
}
}
DataService가 MySQLRepository라는 구체적인 구현에 직접 의존(new)하고 있습니다. 데이터 저장소를 Oracle로 변경하려면 DataService의 코드를 수정해야 합니다.
다음은 생성자를 통해 외부에서 의존성을 주입하는 방식입니다. 이를 통해 DIP를 준수한 설계를 할 수 있습니다.
// 추상화
interface Repository {
save();
}
// 구체적인 구현: MySQLRepository
class MySQLRepository implements Repository {
@Override
public void save(String data) {
//mysql 로직
}
}
// 구체적인 구현: OracleRepository
class OracleRepository implements Repository {
@Override
public void saveData(String data) {
//오라클 로직
}
}
class DataService {
private final Repository repository;
// 생성자를 통한 의존성 주입
public DataService(Repository repository) {
this.repository = repository; // 추상화에 의존함.
}
public void save(String data) {
repository.save(data);
}
}
public class Main {
public static void main(String[] args) {
// MySQLRepository 사용(외부에서 구현체를 주입)
Repository mysqlRepository = new MySQLRepository();
DataService serviceWithMySQL = new DataService(mysqlRepository);
// OracleRepository 사용
Repository oracleRepository = new OracleRepository();
DataService serviceWithOracle = new DataService(oracleRepository);
}
}
DIP를 준수하면 다음의 이점이 있습니다.
- 새로운 데이터베이스 저장소를 추가하려면 Repository 인터페이스를 구현하는 새로운 클래스를 작성하기만 하면 됩니다. 예를 들어, PostgreSQL이나 MongoDB와 같은 데이터베이스를 쉽게 추가할 수 있습니다.
- 의존하는 모듈(DataService)의 코드를 수정하지 않아도 새로운 데이터베이스 저장소를 추가할 수 있습니다. 이는 시스템의 유지보수성과 확장성을 향상시킵니다.
'컴퓨터 > JAVA' 카테고리의 다른 글
JAVA - Enum 클래스 (0) | 2024.10.25 |
---|---|
JUnit5 기초 (0) | 2024.10.24 |
JAVA - Generic(제너릭) (0) | 2024.09.30 |
OOP - 객체지향프로그래밍 기초 - 인터페이스(Interface)의 응용(낮은 결합) (0) | 2023.04.25 |
OOP - 객체지향프로그래밍 기초 - 다형성(Polymorphism) (0) | 2023.04.24 |