Spring

[Spring] SOLID 원칙과 Spring의 등장 배경

kimyongjun0129 2025. 5. 15. 13:27

 


 

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를 실용적으로 구현할 수 있게 도와줍니다.