SOLID 원칙, Spring Container

star_pooh·2024년 12월 16일
1

TIL

목록 보기
34/39
post-thumbnail

SOLID 원칙

객체 지향 설계의 5가지 기본 원칙으로서 소프트웨어 설계에서 유지보수성, 확장성, 유연성을 높이기 위한 지침.

단일 책임 원칙(Single Responsibility Principle)

하나의 클래스는 하나의 책임만 가져야 한다.

// User 클래스는 사용자 정보 관리, 로그인 및 데이터베이스 저장 책임을 동시에 가지고 있음
public class User {
	private String name; // 사용자 정보
    public void login() { /* 로그인 기능 */ }
    public void saveUser() { /* 데이터베이스 저장 기능 */ }
}

👇 단일 책임 원칙 적용 후

public class User { 
	// 사용자 정보 관리
}

public class AuthService {
    public void login(User user) {
    	// 로그인 기능
    }
}

public class UserRepository {
    public void saveUser(User user) { 
    	// 데이터베이스 저장
    }
}
  • 클래스는 한 가지 기능에 집중해야 하며, 그 외의 기능을 담당하지 않아야 함
  • 클래스가 변경될 때 파급 효과가 작아야 함
  • 상황에 따라 책임의 크기가 달라질 수 있음

개방 폐쇄 원칙(Open Closed Principle)

소프트웨어 요소는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.

public class Shape {
    public String type;
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        if (shape.type.equals("circle")) {
            return 원의 넓이 계산;
        } else if (shape.type.equals("square")) {
            return 사각형의 넓이 계산;
        }
    }
}

👇 개방 폐쇄 원칙 적용 후

public interface Shape {
    double calculateArea();
}

public class Circle implements Shape {
    public double calculateArea() { 
    	return 원의 넓이 계산; 
    }
}

public class Square implements Shape {
    public double calculateArea() { 
    	return 사각형의 넓이 계산; 
    }
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        return shape.calculateArea();
    }
}
  • 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있도록 설계해야 함
    • 다형성을 활용하여 해결
    • OCP 적용 전에는 새로운 도형이 추가될 때마다 AreaCalculator 클래스를 수정해야함
    • OCP 적용 후에는 새로운 도형이 추가되더라도 Shape 인터페이스만 구현하면 AreaCalculator는 수정할 필요가 없음

💥 문제점

// Circle을 계산하는 경우
public class Main {
	public static void main(String[]) {
    	AreaCalculator areaCalculator = new AreaCalculator();
        Circle circle = new Circle();
        areaCalculator.calculate(circle);
    }
}

// Square를 계산하는 경우
public class Main {
	public static void main(String[]) {
        AreaCalculator areaCalculator = new AreaCalculator();
        // Circle circle = new Circle();
        Square square = new Square();
        areaCalculator.calculate(square);
    }
}
  • 구현 객체를 변경하기 위해서는 해당 코드를 사용하는 클라이언트측의 코드를 변경해야 함
  • 객체의 생성, 사용 등을 자동으로 설정해주는 무엇인가가 필요해짐
    • Spring Container의 역할

리스코프 치환 원칙(Liskov Substitution Principle)

자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.

class Car {
    public void accelerate() {
        System.out.println("자동차가 휘발유로 가속합니다.");
    }
}

class ElectricCar extends Car {
    @Override
    public void accelerate() {
        throw new UnsupportedOperationException("전기차는 이 방식으로 가속하지 않습니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.accelerate(); // "자동차가 가속합니다."

        Car electricCar = new ElectricCar();
        electricCar.accelerate(); // UnsupportedOperationException 발생
    }
}

👇 리스코프 치환 원칙 적용 후

interface Acceleratable {
    void accelerate();
}

class Car implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("내연기관 자동차가 가속합니다.");
    }
}

class ElectricCar implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("전기차가 배터리로 가속합니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Acceleratable car = new Car();
        car.accelerate(); // "내연기관 자동차가 가속합니다."

        Acceleratable electricCar = new ElectricCar();
        electricCar.accelerate(); // "전기차가 배터리로 가속합니다."
    }
}
  • 부모 클래스를 사용하는 곳에서 자식 클래스를 사용해도 프로그램의 동작에 문제가 없어야 함
    • ElectricCar는 Car 클래스를 상속 받았지만, accelerate() 를 사용할 수 없음
    • 인터페이스를 구현한 구현체를 믿고 사용할 수 있도록 만들어줌

인터페이스 분리 원칙(Interface Segregation Principle)

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

public interface Animal {
    void fly();
    void run();
    void swim();
}

public class Dog implements Animal {
    public void fly() { /* 사용하지 않음 */ }
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}

👇 인터페이스 분리 원칙 적용 후

public interface Runnable {
    void run();
}

public interface Swimmable {
    void swim();
}

public class Dog implements Runnable, Swimmable {
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}
  • 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 함
    • Dog 클래스는 fly() 메소드를 사용하지 않지만 구현 해야함
    • 하나의 큰 인터페이스보다는 여러 개의 작은 인터페이스로 분리해야 함
      • 인터페이스가 명확해짐

의존관계 역전 원칙(Dependency Inversion Principle)

구체적인 클래스에 의존하지 말고, 인터페이스나 추상 클래스에 의존하도록 설계해야 한다.

class EmailNotifier {
    public void sendEmail(String message) {
        System.out.println("Email 알림: " + message);
    }
}

class NotificationService {
    private EmailNotifier emailNotifier;

    public NotificationService() {
		// 구체적인 클래스인 EmailNotifier에 의존
        this.emailNotifier = new EmailNotifier();
    }

    public void sendNotification(String message<) {
        emailNotifier.sendEmail(message);
    }
}

public class Main {
    public static void main(String[] args) {
        NotificationService service = new NotificationService();
        service.sendNotification("안녕하세요! 이메일 알림입니다.");
    }
}

👇 의존관계 역전 원칙 적용 후

interface Notifier {
    void send(String message);
}

class EmailNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("Email 알림: " + message);
    }
}

class SMSNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("SMS 알림: " + message);
    }
}

class NotificationService {
    private Notifier notifier;

    public NotificationService(Notifier notifier) {
        this.notifier = notifier;
    }

    public void sendNotification(String message) {
        notifier.send(message);
    }
}

public class Main {
    public static void main(String[] args) {
        // Email 알림을 사용
        Notifier emailNotifier = new EmailNotifier();
        NotificationService emailService = new NotificationService(emailNotifier);
        emailService.sendNotification("안녕하세요! 이메일 알림입니다.");

        // SMS 알림을 사용
        Notifier smsNotifier = new SMSNotifier();
        NotificationService smsService = new NotificationService(smsNotifier);
        smsService.sendNotification("안녕하세요! SMS 알림입니다.");
    }
}
  • 추상화된 Notifier 인터페이스에만 의존
    • DIP 적용 전에는 SMS 알림과 같은 기능이 추가 되면 NotificationService가 수정 되어야 함
    • DIP 적용 후에는 NotificationService이 수정되지 않아도 됨
  • 필요한 Notifier 객체를 외부에서 주입받음
    • NotificationService는 어떤 알림 방식을 사용할지에 대한 세부 사항을 몰라도 되므로, 의존성이 약해짐
  • 모듈간의 결합도를 낮추고 유연성과 확장성을 높일 수 있음
  • 서로의 변경 사항에 독립적이어서 변경에 유연함

Spring과 객체지향

객체 지향의 핵심은 다형성에 있지만, 다형성만으로는 OCP, DIP를 지킬 수 없음. Spring은 IOC(Inversion Of Control), DI(Dependency Injection)을 통해 OCP, DIP가 가능하도록 만들어줌

  • Spring의 역할
    • OCP, DIP 원칙을 지킬 수 있도록 도와줌
    • 코드의 변경 없이 기능을 확장할 수 있도록 만들어줌
    • 개발자가 원하는 구성 요소를 손쉽게 교체하고 결합할 수 있도록 만들어줌
  • 개발자의 역할
    • 이상적으로는 모든 설계를 인터페이스로 만들어야 코드가 유연하게 변경이 가능해짐
      • 정해진 비지니스 로직이나 사용할 기술이 없는 상황에서도 개발할 수 있는 장점을 가짐

Spring Container

Spring Container

Spring으로 구성된 애플리케이션에서 객체(Bean)를 생성, 관리, 소멸하는 역할을 담당. 애플리케이션 시작 시, 설정 파일이나 Annotation을 읽어 Bean을 생성하고 주입하는 모든 과정을 담당

  • 객체를 직접 생성하는 경우, 객체 간의 의존성 및 결합도가 높아짐
    • OCP, DIP 위반
  • Spring Container를 사용하면 인터페이스에만 의존하는 설계가 가능해짐
    • OCP, DIP 준수

  • Spring Container의 종류
    • BeanFactory
      • Spring Container의 최상위 인터페이스
      • Spring Bean을 관리하고 조회함
    • ApplicationContext
      • BeanFactory의 확장된 형태(implements)
      • Application 개발에 필요한 다양한 기능을 추가적으로 제공
        • 국제화, 환경변수 분리, 이벤트, 리소스 조회

💡 일반적으로 ApplicationContext를 사용하기 때문에 ApplicationContext를 Spring Container라 표현함

Spring Bean

스프링 컨테이너가 관리하는 객체를 의미. 자바 객체 자체는 특별하지 않지만, 스프링이 객체를 관리하는 순간부터 Bean이 됨. Spring은 Bean을 생성, 초기화, 의존성 주입 등을 통해 관리.

  • Spring Bean의 특징
    • 스프링 컨테이너에 의해 생성되고 관리됨
    • 기본적으로 Singleton으로 설정
    • 의존성 주입(DI)을 통해 다른 객체들과 의존 관계를 맺을 수 있음
    • 생성, 초기화, 사용, 소멸의 생명주기를 가짐

IOC(제어의 역전, Inversion Of Control)

객체의 생성과 관리 권한을 개발자가 아닌 스프링 컨테이너가 담당하는 것. 스프링 컨테이너가 객체의 생성, 주입, 소멸을 관리하며, 객체 간의 결합도는 낮추기 때문에 유연한 코드가 됨.

DI(의존성 주입, Dependency Injection)

스프링이 객체 간의 의존성을 자동으로 주입해주는 것. 객체가 다른 객체를 사용할 때, 해당 객체를 직접 생성하지 않고 Spring이 주입해주는 방식이며, IOC를 구현하는 방식 중 하나.

싱글톤 패턴(Singleton Patter)

클래스의 인스턴스가 오직 하나만 생성되도록 보장하는 디자인 패턴.

  • 요청을 할 때 마다 객체가 새로 생성되며, 처리가 완료되면 소멸
  • 메모리 낭비가 심함
  • 객체가 한번만 생성되어 리소스 절약 가능
  • 싱글톤 패턴 생성 예제
public interface Singleton {
	void showMessage();
}

public class SingletonImpl implements Singleton {
    private static SingletonImpl instance;

    // private 생성자를 통해 외부에서 객체 생성을 방지
    private SingletonImpl() {}

    // public으로 설정하여 인스턴스가 필요하면
    // getInstance 메서드를 통해 인스턴스에 접근하도록 만듦
    public static SingletonImpl getInstance() {
        // 인스턴스가 없을 때만 생성
        if (instance == null) {
            instance = new SingletonImpl();
        }
        return instance;
    }

    @Override
    public void showMessage() {
    	// 인스턴스 주소값 출력
        System.out.println(instance.toString());
    }
}

💥 싱글톤 패턴의 문제점

  • 싱글톤 패턴을 구현하기 위한 코드의 양이 많음
  • 구현 클래스에 의존(DIP, OCP 위반)
    • 유연성이 떨어지기 때문에 안티 패턴이라고도 불림
  • 스프링의 싱글톤 컨테이너
  • 스프링 컨테이너는 싱글톤 패턴의 문제점들을 해결하면서 객체를 싱글톤으로 관리

싱글톤 패턴의 주의점

객체의 인스턴스를 하나만 생성하여 공유하기 때문에 싱글톤 패턴의 객체는 상태를 유지(stateful)하면 안됨.

public class StatefulSingleton {
    private static StatefulSingleton instance;
    private int value;

	private StatefulSingleton() {}

    public static StatefulSingleton getInstance() {
        if (instance == null) {
            instance = new StatefulSingleton();
        }
        return instance;
    }

    public void setValue(int value) {
        this.value = value;
    }

    public int getValue() {
        return this.value;
    }
}

public class MainApp {
    public static void main(String[] args) {
        // 클라이언트 1: 싱글톤 인스턴스를 가져와서 상태를 설정
        StatefulSingleton client1 = StatefulSingleton.getInstance();
        client1.setValue(42);
        System.out.println("클라이언트 1이 설정한 값: " + client1.getValue());

        // 클라이언트 2: 동일한 싱글톤 인스턴스를 사용해 상태를 변경
        StatefulSingleton client2 = StatefulSingleton.getInstance();
        client2.setValue(100);
        System.out.println("클라이언트 2가 설정한 값: " + client2.getValue());

        // 클라이언트 1이 다시 값을 확인
        System.out.println("클라이언트 1이 다시 확인한 값: " + client1.getValue());
    }
}
클라이언트 1이 설정한 값: 42
클라이언트 2가 설정한 값: 100
클라이언트 1이 다시 확인한 값: 100
  • 상태 유지(stateful)의 문제점
    • 데이터의 불일치나 동시성 문제가 발생할 수 있음
    • Spring Bean은 항상 무상태(stateless)로 설계해야 함
      • 특정 클라이언트에 의존적인 필드가 있거나 변경할 수 있으면 안됨

💡 스프링 컨테이너가 해결한 싱글톤 패턴의 문제점
1. 코드의 복잡성 및 테스트 어려움

  • 문제점: 싱글톤 패턴에서는 싱글톤을 구현하기 위해 private 생성자와 static 메서드 등을 사용해야 한다. 이러한 코드 작성은 복잡하고 테스트를 어렵게 만든다. 특히, 전역 상태를 가지는 싱글톤 객체는 테스트 중 다른 테스트에 영향을 줄 수 있다.
  • 스프링의 해결: 스프링 컨테이너가 객체의 생명 주기를 관리하므로, 개발자가 직접 싱글톤 구현을 작성할 필요가 없다. 또한 스프링은 객체를 DI로 주입하기 때문에 테스트 환경에서 다른 객체로 쉽게 대체 가능하여 테스트 용이성을 높인다.

2. 멀티쓰레드 환경에서의 동기화 문제

  • 문제점: 싱글톤 패턴에서는 멀티쓰레드 환경에서 안전하게 동작하도록 동기화 코드를 작성해야 한다. 그러나 동기화 코드는 성능 저하를 유발할 수 있다.
  • 스프링의 해결: 스프링 컨테이너는 내부적으로 안전하게 싱글톤 객체를 관리하므로, 개발자가 동기화 문제를 신경 쓰지 않아도 된다.

3. 객체 생명 주기 관리의 어려움

  • 문제점: 싱글톤 패턴으로 생성한 객체는 어플리케이션이 종료될 때까지 유지되므로, 사용하지 않더라도 메모리를 점유할 수 있다. 또한 명시적으로 해제하는 코드가 필요할 수도 있다.
  • 스프링의 해결: 스프링 컨테이너는 싱글톤 객체를 생성, 초기화, 사용, 소멸 단계까지 관리한다. @PostConstruct@PreDestroy 같은 어노테이션을 통해 초기화 및 소멸 작업을 간편하게 처리할 수 있다.

4. 전역 상태 문제

  • 문제점: 싱글톤 객체는 전역 상태를 가질 수 있기 때문에, 한 곳에서 상태를 변경하면 다른 곳에서 의도치 않은 부작용이 발생할 수 있다.
  • 스프링의 해결: 스프링은 필요한 경우 프로토타입 스코프(@Scope("prototype"))나 요청 스코프(@Scope("request")) 등 다양한 스코프를 지원하여 전역 상태 문제를 완화한다. 또한, 상태를 가지는 빈 대신 상태 없는 빈(stateless bean)을 권장한다.

5. DI 지원 부족

  • 문제점: 싱글톤에서는 객체 간의 의존성을 관리하기 어렵다. 의존성이 많아질수록 코드가 복잡해지고 결합도가 높아진다.
  • 스프링의 해결: 스프링은 DI를 통해 객체 간 의존성을 관리하므로, 결합도를 낮추고 코드의 유연성과 재사용성을 높인다.

0개의 댓글

관련 채용 정보