[Spring] SOLID 원칙과 Spring의 등장 배경
목차
SOLID 원칙
1. 단일 책임 원칙 (SRP : Single Responsibility Principle)
정의 :
클래스는 오직 하나의 책임만 가져야 합니다.
예제 코드 :
❌ SRP 위반 예시 (한 클래스에 두 가지 책임)
class Employee {
public void work() {
System.out.println("일하는 중..");
}
pubilc void saveToDatabase() {
System.out.println("데이터베이스에 저장 중...");
}
}
- `Employee` 클래스는 업무 수행과 DB 저장이라는 두 가지 책임을 가집니다.
✅ SRP 준수 예시 (책임 분리)
class Employee {
public void work() {
System.out.println("일하는 중...");
}
}
class EmployeeRepository {
public void save (Employee emp) {
System.out.println("데이터베이스에 저장 중...");
}
}
- `Employee`는 일만 수행합니다
- `EmployeeRepository`는 저장만 합니다.
👍이렇게 SRP를 지켜 역할을 분리하면, 각 클래스가 하나의 변경 이유만 가지게 됩니다.
2. 개방-폐쇄 원칙 (OCP : Open-Closed Principle)
정의 :
기존의 코드를 변경하지 않고 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다.
예제 코드 :
❌ OCP 위반 예시
class Greeting {
public void sayHello (String lang) {
if (lang.equals("ko")) {
System.out.println("안녕하세요");
} else if (lang.equals("en")) {
System.out.println("Hello");
} else if (lang.equals("jp")) {
System.out.println("こんにちは");
}
}
}
- 언어를 추가할 때마다 `sayHello()` 메서드를 수정해야 합니다.
→ 수정에 닫혀 있지 않음 = OCP 위반
✅ OCP 준수 예시 (기존 코드는 변경 없음, 확장 가능)
interface LanguageGreeting {
void sayHello();
}
class KoreanGreeting implements LanguageGreeting {
public void sayHello();
System.out.println("안녕하세요");
}
}
class EnglishGreeting implements LanguageGreeting {
public void sayHello();
System.out.println("Hello");
}
}
class JapanseGreeting implements LanguageGreeting {
public void sayHello();
System.out.println("こんにちは");
}
}
class Greeter {
public void greet(LanguageGreeting greeting);
greeting.sayHello();
}
}
사용 예시
public class Main {
public static void main(String[] args) {
Greeter greeter = new Greeter();
greeter.greet(new KoreanGreating()); // 안녕하세요
greeter.greet(new EnglishGreating()); // Hello
greeter.greet(new JapaneseGreating()); // こんにちは
}
}
- 언어를 추가하려면 `LanguageGreeting` 인터페이스를 새 클래스로 구현하기만 하면 됩니다.
- `Greeter` 클래스는 수정하지 않고도 확장이 가능합니다.
- 이것이 가능한 이유는 객체 지향의 다형성을 활용했기 때문입니다.
👍이렇게 OCP를 지키면, 기존 코드를 수정하지 않고도 확장이 가능합니다.
❗"수정에는 닫혀 있다" 의미 헷갈리지 않게 조심
- "새로운 클래스를 추가 및 구현"하는 것 → 확장
- "기존 클래스 안의 조건문이나 로직을 변경"하는 것 → 수정
3. 리스코프 치환 원칙 (LSP : Liskov Substitution Principle)
정의 :
자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 합니다. 즉, 부모 클래스 타입으로 자식 클래스를 사용했을 때 프로그램이 문제 없이 작동해야 한드는 원칙입니다.
예제 코드 :
❌ LSP 위반 예시
class Bird {
public void fly() {
System.out.println("날아갑니다!");
}
}
class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("타조는 날 수 없습니다!"); // Runtime Exception
}
}
- `Bird`를 상속한 `Ostrich`는 `fly()` 메서드를 재정의해서 예외를 던집니다.
- 즉, `Bird` 타입으로 `Ostrich`를 사용하면 기대한 동작(비행)이 깨집니다.
→ 리스코프 치환 원칙 위반
✅ LSP 준수 예시
interface Flyable {
void fly();
}
class Sparrow implements Flyable {
public void fly() {
System.out.println("참새가 날아갑니다!");
}
}
class Ostrich {
public void walk() {
System.out.println("참새가 날아갑니다!");
}
}
- 날 수 있는 새(`Sparrow`)만 `Flyable`을 구현합니다.
- 날지 못하는 새(`Ostrich`)는 `Flyable`을 구현하지 않습니다.
- 이제 `Flyable`만 사용하는 코드에서는 문제가 생기지 않습니다. → LSP 준수
❗ 단순히 `implements Flyable`을 했다고 LSP를 지키는 것이 아니라,
→ 그 행동이 의미상 부모의 기대를 깨지 않아야 LSP를 지킨 것입니다.
4. 의존 역전 원칙 (DIP : Depdendency Inversion Principle)
정의 :
상위 모듈은 하위 모듈에 의존해서는 안 된다는 원칙입니다. 둘 다 추상(인터페이스)에 의존해야 합니다. 또한 구체에 의존하지 않고, 구체가 추상에 의존해야 합니다.
예제 코드 :
❌ DIP 위반 예시
class EmailService {
public void sendEmail(String message) {
System.out.println("이메일 발송 : " + message);
}
}
class Notfication {
private EmailService emailService = new EmailService(); // 구체 클래스에 의존
public void alert(String msg) {
emailService.sendEmail(msg);
}
}
- `Notification`은 `EmailService`라는 구체 클래스에 직접 의존합니다.
- 만약 알림 방식을 `SMS`로 바꾸려면 `Notification` 코드도 수정해야 합니다.
→ DIP 위반
✅ DIP 준수 예시
1. 추상화 도입
interface MessageService {
void send(String message);
}
2. 구체 구현
class EmailService implements MessageService {
public void send(String message) {
System.out.println("이메일 발송 : " + message);
}
}
class SmsService implements MessageService {
public void send(String message) {
System.out.println("SMS 발송 : " + message);
}
}
3. 상위 모듈이 추상에 의존
class Nofitication {
private MessageService service;
public Notification (MessageService service) { // 생성자 매겨변수를 인터페이스로 받는다.
this.service = service;
}
public void alert (String msg) {
service.send(msg);
}
}
4. 사용하는 쪽에서 의존성 주입
public class Main {
public static void main(String[] args) {
MessageService email = new EmailService();
Notification notification = new Notification(email);
notification.alert("의존 역전 원칙 테스트 중입니다.");
MessageService sms = new SmsService();
Notification smsNotification = new Notification(sms);
smsmNotification.alert("문자로도 보낼 수 있습니다.");
}
}
- main에서 `EmailService`와 `SmsService`를 객체화시켜, Notification의 생성자로 전달합니다. (의존성 주입)
- 만약, `Notification`이 생성자로 인터페이스가 아닌 구체 클래스에 의존했다면, 그 특정 의존 클래스만 생성자로 넘겨 받을 수 있습니다.
5. 인터페이스 분리 원칙 (ISP : Interface Segregation Principle)
정의 :
클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안된다는 원칙입니다. 즉, 큰 인터페이스 하나를 여러 개의 작은 인터페이스로 나눠야 합니다.
예제 코드 :
❌ ISP 위반 예
interface Worker {
void work();
void eat();
}
class Robot implements Worker {
public void work() {
System.out.println("로봇이 일합니다.");
}
public void eat() {
// 로봇은 먹지 않는데도 억지로 구현
throw new UnsupportedOperationException("로봇은 먹지 않음");
}
}
- `Robot`은 `eat()` 메서드를 사용할 필요가 없는데도 구현해야 합니다.
→ ISP 위반
✅ ISP 준수 예시
1. 인터페이스 분리
interface Workable {
void work();
}
interface Eatable {
void eat();
}
2. 각각 필요한 인터페이스만 구현
class Human implements Workable, Eatable {
public void work() {
System.out.println("사람이 일합니다.");
}
public void eat() {
System.out.println("사람이 밥을 먹습니다.");
}
}
class Robot implements Workable {
public void work() {
System.out.println("로봇이 일합니다.");
}
}
- `Robot`은 `Worable`만 구현 → 불필요한 의존 없음 → ISP 준수합니다.
❗SOLID 원칙을 잘 지키면, 좋은 코드를 짤 수 있을까요? 아닙니다. 다음과 같은 이유로 SOLID 원칙을 무조건 지킨다고만 해서는 좋은 코드를 짜는건 아닙니다.
그럼 SOLID 원칙은 무적인가?
SOLID 원칙을 철저히 따르려고 할수록 코드가 처음에는 더 복잡하게 보일 수 있습니다. 그 이유는 다음과 같습니다.
1. 추상화(Abstraction)가 많아집니다
- SOLID 원칙 중 많은 부분은 구체적인 구현보다 인터페이스나 추상 클래스를 활용하라고 권장합니다.
- 이 과정에서 파일 수가 늘어나고, 한 눈에 구조를 파악하기 어려워지는 단점이 있습니다.
예시 : 단순한 기능에 과도한 인터페이스 사용
interface NotificatonSender {
void send (String message);
}
class EmailSender implements NotificationSender {
public void send(String message) {
System.out.println("Email : " + message);
}
}
class NotificationService {
private NotificationSender sender;
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void notify(String message) {
sender.send(message);
}
}
- 단순히 이메일만 보내면 되는 기능에 `interface`, `DI`, `Service` 구조까지 등장하게 됩니다.
- 소규모 앱이나 간단한 기능에는 비교적 구조가 복잡해 보이게 됩니다.
예시 : 단순화한 경우
class NotificationService {
public void notify(String message) {
System.out.println("Email: " + message);
}
}
- 항상 이메일만 보낸다면, 위의처럼 더 단순하게 사용할 수 있습니다.
2. 역할 분리가 많아집니다 (SRP - 단일 책임 원칙)
- 한 클래스가 하나의 책임만 가지도록 하려면, 클래스 수가 늘어납니다.
예시 : 단일 기능을 여러 클래스로 나눈 경우
class UserInputValidator { /* 입력값 검증 */ }
class UserSanitizer { /* 문자열 정리 */ }
class UserStorage { /* DB 저장 */ }
class UserLogger { /* 로깅 */ }
class UserController {
// 각각의 클래스를 조합해서 회원가입 처리
}
- 기능은 회원가입 하나인데 파일이 5개가 됩니다.
- 간단한 웹사이트라면 오히려 유지보수가 더 힘들어 질 수 있습니다.
3. 구성(Composition)이 증가합니다
- 상속보다는 조합(composition)을 권장하며, 기능을 작은 단위로 쪼개어 합성하는 방식으로 바뀝니다.
- 이로 인해 간단한 작업조차도 많은 객체들 간의 협력을 필요로 합니다.
예시 : 단순한 작업을 여러 객체가 처리
class AuthService {
public boolean authenticate(String token) {
return token.equals("valid-token");
}
}
class DataFetcher {
public String fetchData() {
return "data";
}
}
class Processor {
public void process(String data) {
System.out.println("Processing " + data);
}
}
class App {
private AuthService auth;
private DataFetcher fetcher;
private Processor processor;
public App(AuthService auth, DataFetcher fetcher, Processor processor) {
this.auth = auth;
this.fetcher = fetcher;
this.processor = processor;
}
public void run(String token) {
if (auth.authenticate(token)) {
String data = fetcher.fetchData();
processor.process(data);
}
}
}
- 역할 분리를 위해 세 클래스가 있지만, 단순한 인증하고 출력하는 기능에 너무 많은 객체가 엮이게 됩니다.
- 단순하게 함수 1~2개로 끝낼 수 있는 작업도 협력 구조가 복잡해져서 디버깅이나 유지보수가 어렵습니다.
예시 : 단순화한 경우
if (token.equals("valid-token")) {
System.out.println("Processing data");
}
4. 유연성은 높아지지만, 초기 비용이 큽니다
- SOLID는 "변화에 유연하게 대응하기 위해" 설계 원칙을 강조합니다.
- 하지만 실제로는 변경이 자주 일어나지 않는 부분도 많기 때문에, 너무 일찍부터 원칙을 적용하면 **과설계(overengineering)**가 될 수 있습니다.
interface Shape {
double getArea();
}
class Circle implements Shape {
public double getArea() { return 3.14 * r * r; }
}
class Square implements Shape {
public double getArea() { return side * side; }
}
- 정작 프로젝트는 원만 그리는 도형 툴일 수도 있습니다.
- 나중에 도형이 추가되지 않으면, 이런 구조는 불필요한 설계 투자가 됩니다.
- 즉, YAGNI(You Aren't Gonna Need It) 원칙 위반
✨ YAGNI(You Aren't Gonna Need It) : 프로그래머가 필요하다고 간주할 때까지 기능을 추가하지 않는 것이 좋다는 익스트림 프로그래밍(XP)의 원칙입니다.
❗무조건 어떤 것으로 해야한다가 아닌, 요구사항에 맞게 코드를 작성하는 것이 좋습니다.
- 현재 요구사항이 단순하다면 : SOLID 구조는 과할 수 있습니다.
- 미래에 변화가 예상된다면 : 지금의 SOLID 구조는 미리 준비된 확장성으로 강점이 됩니다.
👍SOLID를 적용하면서 생기는 복잡성(특히, 역할 분리, 의존성 관리의 부담)으로 인해 현실적인 개발 환경에서의 한계가 있습니다. 이러한 한계를 극복하기 위해 `Spring` 을 활용합니다.
Spring
정의 :
자바 기반의 웹 애플리케이션 프레임워크로, 주요 목표는 복잡한 객체 관리, 의존성 주입, 모듈화된 설계를 쉽게 만들어 주는 것입니다.
간단한 예제를 통해 Spring 동작 과정을 살펴봅시다.
✅ 예제 구조
- "Spring"이라는 이름을 입력하면 `Hello, Spring!`이라는 인사말을 출력하는 프로그램입니다.
🧱 코드 구조 요약
- `GreetingService` : 인사 메시지를 반환하는 서비스
- `AppRunner` : 앱 실행 시 인사 메시지를 출력하는 클래스
- `Application` : 프로그램 시작하는 클래스
GreetingService.java
import org.springframework.stereotype.Component;
@Component // Bean으로 등록
public class GreetingService {
public String greet(String name) {
return "Hello, " + name + "!";
}
}
- `@Component`: 이 클래스를 Spring이 관리하는 객체(Bean)로 등록해주는 어노테이션입니다.
→ 나중에 개발자가 직접 코드로 객체 생성(`new GreetingService();`)후, 주입하지 않아도 Spring이 대신 생성하여 주입해줍니다.
→ 우리는 그저 새로운 구현체가 생기면 `@Component`를 붙여주기만 하면 됩니다.
AppRunncer.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class AppRunner {
private final GreetingService greetingService;
@Autowired // DI (의존성 주입)
public AppRunner(GreetingService greetingService) {
this.greetingService = greetingService;
}
public void run() {
String message = greetingService.greet("Spring");
System.out.println(message);
}
}
- `@Component` : 이 클래스를 Spring이 관리하는 객체(Bean)로 등록해주는 어노테이션입니다.
→ 나중에 개발자가 직접 코드로 객체 생성(`new AppRunner();`)후, 주입하지 않아도 Spring이 대신 생성하여 주입해줍니다. - GreetingService를 생성자 파라미터로 받는데, `@Autowired` 덕분에 Spring이 알아서 GreetingService 객체를 만들어서 넣어줍니다.
→ `Bean`으로 등록이 되어있어야만 가능하기 때문에, 위에서 GreetingService를 `Bean`으로 등록한 것입니다.
Main Application - Application.java
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
public class Application implements CommandLineRunner {
private final AppRunner appRunner;
public Application(AppRunner appRunner) {
this.appRunner = appRunner;
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Override
public void run(String... args) {
appRunner.run();
}
}
- `@SpringBootApplication` : Spring Boot 프로그램의 시작을 알려주는 어노테이션입니다.
- `@CommponentScan`을 포함하고 있습니다.
- `@CommponentScan` : 자동으로 `@Component`가 붙은 클래스 들을 찾아 `Bean`으로 등록해주는 어노테이션입니다.
- CommandLineRunner: 이 인터페이스를 구현하면, 프로그램 실행 직후에 run()이 호출됩니다.
- 여기선 AppRunner를 주입받아(@Autowired 없이도 생성자 주입 가능),
run() 메서드를 호출해서 프로그램을 시작합니다.
✨ 위 예제에서 어떤 부분이 개발자의 부담을 덜어주는가?
1. 객체 생성과 연결을 자동으로 처리
- 예전에는 `new GreetingService()`하고 직접 `AppRunner`에 넣어줘야 했습니다.
- 역할을 분리하면 클래스가 많아지니까, 누가 누구를 생성하고 주입하는지 관리하기 점점 복잡해집니다.
→ Spring은 이걸 `@Component + @AutoWired`로 자동화해서 분리하면서도 연결은 알아서 해줍니다.
2. 확장에 유리하지만 변경은 없음
- 구조적으로 `GreetingService`의 동작을 바꾸고 싶으면 새 클래스를 만들고 주입 대상만 바꾸면 됩니다.
→ 기존 코드 수정 없이 확장 가능 (OCP) - 주입 받고자하는 클래스의 생성자 매개변수만 변경해 주면 됩니다! (생성자 주입 방식을 사용하는 경우!)
→ 여러 역할을 나눴어도 결합도를 낮춰서 부담을 줄입니다.
3. 클래스 수는 늘어나지만, 관리할 책임은 줄어듦
- SRP, DIP 등을 지키려면 클래스가 자연스럽게 많아집니다
- 근데 그걸 Spring이 대신 찾아서 관리해 줍니다. (`@ComponentScan`)
→ 우리가 책임을 분산시켜도, 시스템이 알아서 조립
정리 - Spring이 SOLID 적용 시 도와주는 이유
문제점 | Spring이 해결해주는 방식 |
클래스가 많아지며 조립이 번거로움` | DI 컨테이너가 자동 조립 (IoC) |
역할 분리 후 파일/객체 많아짐 | 어노테이션으로 구조 파악 명확 |
인터페이스-구현체 설계가 번거로움 | DI + 프록시/구현체 교체가 쉬움 |
✨Spring은 SOLID의 단점(복잡성)을 구조적으로 받아들이고, 자동화와 어노테이션 기반 설정으로 사용자는 부담 없이 적용하게 도와줍니다
결론
SOLID는 이상적인 설계 원칙이지만, 실제로 모두 적용하려하면 복잡도와 관리 부담으로 인해 현실적으로 개발이 어려울 수 있습니다. Spring은 이런 문제를 자동화된 DI와 컴포넌트 스캔, 실행 흐름 제어 등을 통해 보완함으로써 SOLID를 실용적으로 구현할 수 있게 도와줍니다.