SOLID : 객체 지향 설계의 5가지 기본 원칙

SRP(Single Responsibility Principle) 단일 책임 원칙

클래스는 한 가지 기능에 집중하고, 그 외의 기능을 담당하지 않는다.

실제로는 상황에 따라 책임의 크기가 달라진다.

 

OCP(Open Closed Principle) 개방 폐쇄 원칙

확장에는 열려있고, 수정에는 닫혀있어야 한다.

새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있도록 설계해야 한다.

 

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

부모 클래스를 사용하는 곳에서 자식 클래스를 사용해도 프로그램의 동작에 문제가 없어야 한다.

Person person = new Developer();
// Developer는 Person의 행위를 모두 수행할 수 있기 때문에, Person에 저장할 수 있는 것이다.

 

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

범용 인터페이스 하나가 아니라 세분화된 작은 인터페이스를 조합해서 사용해야 한다.

클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.

public class Person implements Runnable, Swinmmable {
	...
}

 

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

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

 

=> 인터페이스의 구현체가 추가, 수정, 삭제되어도, 해당 구현체의 존재를 모르기 때문에 영향을 받지 않는다.

 


 

OCP, DIP 원칙의 한계

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();
    }
}
public class Main {
    public static void main(String[]) {
	AreaCalculator areaCalculator = new AreaCalculator();
        
        Circle circle = new Circle();	// 구현체를 변경할 때마다 코드 수정 필요
        Square square = new Square();	// Circle -> Square
        
        areaCalculator.calculate(circle);
        areaCalculator.calculate(square);
    }
}

 

추상화, 다형성 구현을 위해 위와 같이 Shape 인터페이스를 구현하는 구현체 Circle, Square를 만들었다.

AreaCalculator는 이 설계 덕분에 Shape의 구현체가 추가, 변경되더라도 넓이를 계산하는 로직을 변경하지 않아도 된다.

하지만 Main 메서드를 보면 결국 어떤 구현체를 사용할지에 대해 알고 있어야 하고, 구현체를 변경할 때마다 코드를 수정해줘야 한다.

Spring은 이러한 다형성, 추상화에 대한 한계를 해결한다.

물론 Spring을 사용하더라도 실무에서는 추상화 과정에서 비용이 발생하기 때문에 기능을 확장할 가능성이 없다면 구현 클래스를 직접 사용하고, 추후 변경된다면 리팩토링을 진행하면 된다.

 


Spring Container의 역할

Spring은 new 키워드로 객체를 생성하지 않고, 어노테이션 또는 XML 설정으로 Bean으로 등록한다.

어노테이션의 경우 @Component(+@Controller, @Service, @Repository)를 적용하면 해당 클래스는 Bean으로 등록된다.

Bean은 Spring Container가 관리하는 객체라고 보면 된다.

Spring Container는 자동으로 의존성을 주입(DI)해서 각 객체들의 의존 관계를 만들어 준다.

 

Spring은 객체를 싱글톤으로 관리한다.

Spring을 사용하지 않는 기존의 싱글톤 패턴은 getInstance() 메서드를 통해 객체가 생성되어 있는지를 확인하고 생성되어 있다면 기존에 만들어진 인스턴스를 반환하는 식으로 코드를 작성해야 한다.

이런 방식으로 싱글톤 패턴을 구현하면 코드의 길이도 길어질 뿐만 아니라, 생성하고자 하는 인스턴스가 getInstance() 메서드를 구현하고 있는 클래스에 의존하게 되므로 OCP, DIP에 맞지 않는다.

public class MainApp {
    public static void main(String[] args) {
       // 첫 번째 싱글톤 인스턴스 요청, 구현클래스.getInstance();
        Singleton instance1 = SingletonImpl.getInstance();
        instance1.showMessage(); // 인스턴스 주소값 출력

        // 두 번째 싱글톤 인스턴스 요청, 구현클래스.getInstance();
        Singleton instance2 = SingletonImpl.getInstance();
        instance2.showMessage(); // 인스턴스 주소값 출력
        
        // 다른 구현체로 바꾸려면 DIP, OCP 위반
        Singleton instance3 = SingletonImplV2.getInstance();
        instance3.showMessage();
    }
}

 

 

스프링의 의존성 주입을 활용하면 OCP, DIP를 준수하면서 싱글톤의 장점을 유지할 수 있다.

스프링에서는 직접 싱글톤 패턴을 구현하지 않고, @Component, @Service, @Repository, @Bean을 사용해서 스프링 컨테이너(ApplicationContext)가 자동으로 해당 객체를 싱글톤으로 관리한다.


자동 bean 등록 vs 수동 bean 등록

자동 Bean 등록(@ComponentScan, @Component)

@Component가 있는 클래스의 앞글자를 소문자로 변경하여 bean 이름으로 등록한다.

 

수동 Bean 등록(@Configuration, @Bean)

@Configuration이 있는 클래스를 bean으로 등록하고 해당 클래스를 파싱해서, @Bean이 있는 메서드를 찾아서 Bean을 생성한다.

이때 해당 메서드의 이름으로 Bean 이름이 설정된다.

// 인터페이스
public interface TestService {
    void doSomething();
}

// 인터페이스 구현체
public class TestServiceImpl implements TestService {
    @Override
    public void doSomething() {
        System.out.println("Test Service 메서드 호출");
    }
}

// 수동으로 빈 등록
@Configuration
public class AppConfig {
    
    // TestService 타입의 Spring Bean 등록
    @Bean
    public TestService testService() {
        // TestServiceImpl을 Bean으로 등록
        return new TestServiceImpl();
    }
    
}

// Spring Bean으로 등록이 되었는지 확인
public class MainApp {
    public static void main(String[] args) {
        // Spring ApplicationContext 생성 및 설정 클래스(AppConfig) 등록
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

        // 등록된 TestService 빈 가져오기
        TestService service = context.getBean(TestService.class);

        // 빈 메서드 호출
        service.doSomething();
    }
}

같은 타입의 Bean이 충돌할 때 해결 방법

기본적으로 자동 bean과 자동 bean끼리 충돌하면, 오류가 발생한다.

자동 bean과 수동 bean이 충돌하면, 수동 bean이 자동 bean 등록을 오버라이딩 하기 때문에 우선권을 가진다.

하지만 스프링 부트에서는 자동 bean과 수동 bean의 충돌도 오류가 발생하게 된다.

1. @Autowired + 필드명

@Autowired는 타입으로 주입을 시도하고, 같은 타입의 bean이 여러 개라면 필드 이름/파라미터 이름으로 매칭한다.

public interface MyService { ... }

@Component
public class MyServiceImplV1 implements MyService { ... }

@Component
public class MyServiceImplV2 implements MyService { ... }

@Component
public class ConflictApp {

	// 필드명을 Bean 이름으로 설정
	@Autowired
	private MyService myServiceImplV2;
	...
}

 

2. Qualifier 사용

bean 등록 시, 추가 구분자를 붙여 준다.

생성자 주입 또는 세터 주입 시 사용 가능하다.

@Component
@Qualifier("firstService")
public class MyServiceImplV1 implements MyService { ... }

@Component
@Qualifier("secondService")
public class MyServiceImplV2 implements MyService { ... }

@Component
public class ConflictApp {

	private MyService myService;

	// 생성자 주입에 구분자 추가
	@Autowired
	public ConflictApp(@Qualifier("firstService") MyService myService) {
			this.myService = myService;
	}
	
	// setter 주입에 구분자 추가
	@Autowired
	public void setMyService(@Qualifier("firstService") MyService myService) {
			this.myService = myService;
	}
	...
}

 

3. @Primary 사용

@Primary로 지정된 Bean이 우선 순위를 가진다.

@Component
public class MyServiceImplV1 implements MyService { ... }

@Component
@Primary
public class MyServiceImplV2 implements MyService { ... }

@Component
public class ConflictApp {

		private MyService myService;

		@Autowired
		public ConflictApp(MyService myService) {
				this.myService = myService;
		}
	...
}

 

@Primary와 @Qualifier의 사용 예시

Oracle, Mysql을 모두 사용할 때, 각 상황에 맞는 메서드 주입하기

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary  // 기본적으로 MySQL을 사용
    public DataSource mysqlDataSource() {
        return DataSourceBuilder.create()
                .url("jdbc:mysql://localhost:3306/mydb")
                .username("mysql_user")
                .password("mysql_password")
                .build();
    }

    @Bean
    public DataSource oracleDataSource() {
        return DataSourceBuilder.create()
                .url("jdbc:oracle:thin:@localhost:1521:orcl")
                .username("oracle_user")
                .password("oracle_password")
                .build();
    }
}
@Service  // MySQL 사용
public class DatabaseService {

    private final DataSource dataSource;

    @Autowired
    public DatabaseService(DataSource dataSource) { // 기본적으로 MySQL 사용
        this.dataSource = dataSource;
    }

    public void connect() {
        System.out.println("사용하는 DB: " + dataSource);
    }
}
@Service  // Oracle 사용
public class OracleDatabaseService {

    private final DataSource dataSource;

    @Autowired
    public OracleDatabaseService(@Qualifier("oracleDataSource") DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void connect() {
        System.out.println("Oracle DB 사용: " + dataSource);
    }
}

 

@Qualifier가 @Primary보다 우선순위가 높기 때문에, 기본적으로 MySQL을 사용할 때는 @Primary로 명시하고,

Oracle을 사용하고 싶은 상황에만 @Qualifier를 통해 해당하는 코드를 주입해준다.