포스트

스프링 핵심 원리 (1) - 좋은 객체 지향 프로그래밍

스프링 핵심 원리 (1) - 좋은 객체 지향 프로그래밍

1. 개요

스프링(Spring) 프레임워크는 한마디로 좋은 객체 지향 프로그래밍(OOP)을 쉽게 할 수 있도록 도와주는 프레임워크이다. 스프링이 제공하는 DI(의존관계 주입), IoC(제어의 역전) 컨테이너는 그 자체가 목적이 아니라, 개발자가 다형성을 활용해 SOLID 원칙을 지키는 애플리케이션을 만들 수 있도록 지원하는 수단이다.


2. 객체 지향 프로그래밍이란

객체 지향 프로그래밍은 프로그램을 여러 개의 독립된 객체(object)들의 협력으로 바라보고 설계하는 방법이다. 각 객체는 데이터(속성)와 기능(메서드)을 함께 가지며, 객체끼리 메시지를 주고받으며 협력해 하나의 애플리케이션을 완성한다.

객체 지향의 핵심 특징은 흔히 다음 네 가지로 요약된다.

  • 추상화(Abstraction): 공통의 속성이나 기능을 묶어 이름을 붙인다.
  • 캡슐화(Encapsulation): 데이터와 로직을 하나로 묶고, 내부 구현을 외부로부터 숨긴다.
  • 상속(Inheritance): 상위 개념의 속성과 기능을 하위 개념이 물려받아 재사용한다.
  • 다형성(Polymorphism): 하나의 타입에 여러 구현체가 존재할 수 있다.

이 중 스프링이 가장 적극적으로 활용하는 특징이 바로 다형성이다.


3. 다형성과 역할·구현

객체 지향에서 다형성을 가장 잘 표현하는 비유가 역할(role)과 구현(implementation)의 분리이다. 자동차를 운전할 때 우리는 “자동차 운전”이라는 역할만 알면 되고, 그 안에 어떤 엔진이 들어있는지(구현)는 몰라도 된다. 자동차를 다른 자동차로 바꿔도 운전하는 방법은 동일하다.

이를 코드로 옮기면 역할은 인터페이스, 구현은 인터페이스를 구현한 클래스가 된다.

1
2
3
4
public interface MemberRepository {
    void save(Member member);
    Member findById(Long memberId);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class MemoryMemberRepository implements MemberRepository {
    private final Map<Long, Member> store = new HashMap<>();

    @Override
    public void save(Member member) {
        store.put(member.getId(), member);
    }

    @Override
    public Member findById(Long memberId) {
        return store.get(memberId);
    }
}
1
2
3
4
5
6
7
8
public class JdbcMemberRepository implements MemberRepository {
    // JDBC를 이용한 실제 DB 저장 구현
    @Override
    public void save(Member member) { /* ... */ }

    @Override
    public Member findById(Long memberId) { /* ... */ }
}

클라이언트(MemberRepository를 사용하는 서비스 코드)는 MemberRepository 인터페이스에만 의존하므로, MemoryMemberRepositoryJdbcMemberRepository로 교체해도 클라이언트 코드는 전혀 변경할 필요가 없다. 이것이 다형성을 이용한 유연하고 변경 가능한 설계의 핵심이다.


4. 좋은 객체 지향 설계의 5가지 원칙 (SOLID)

다형성만으로는 좋은 설계를 보장할 수 없다. 다형성을 실제로 “잘” 활용하기 위한 5가지 원칙이 바로 SOLID이다.

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

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

여기서 “책임”의 크기는 상황과 문맥에 따라 다르지만, 기준은 변경이다. 하나의 클래스를 수정했을 때 파급 효과가 적을수록 단일 책임 원칙을 잘 지킨 것이다. 예를 들어 UI 변경, 객체의 생성과 연결, 핵심 비즈니스 로직 실행은 서로 다른 책임이므로 각각 다른 객체가 담당하는 것이 바람직하다.

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

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

새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장만으로 처리할 수 있어야 한다는 뜻이다. 다형성을 활용하면 인터페이스를 구현한 새 클래스를 추가하는 것만으로 기능을 확장할 수 있다.

1
2
3
4
5
6
7
8
9
// MemoryMemberRepository -> JdbcMemberRepository로 구현체를 바꿔도
// MemberService의 코드는 변경되지 않는다.
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

MemberService 코드 자체는 인터페이스에만 의존하므로 OCP를 잘 지킨 예시다. 다만 이 MemberService에 실제로 어떤 구현체를 넣어줄지는 누군가가 결정해서 조립해야 한다.

1
2
3
4
5
6
7
// 클라이언트가 구현체를 직접 생성하고 조립하는 경우
public class OrderApp {
    public static void main(String[] args) {
        MemberRepository memberRepository = new MemoryMemberRepository(); // 구현체 결정
        MemberService memberService = new MemberService(memberRepository);
    }
}

이렇게 애플리케이션 코드 곳곳에서 new MemoryMemberRepository()처럼 구현체를 직접 생성해 조립하면, 구현체를 JdbcMemberRepository로 바꿀 때마다 이 조립 코드를 전부 찾아 수정해야 하므로 그 지점에서 OCP가 깨진다. 이 “조립” 책임을 애플리케이션 로직에서 분리해 대신 수행해주는 것이 바로 스프링의 DI 컨테이너다.

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

하위 타입은 언제나 상위 타입으로 교체할 수 있어야 한다.

단순히 컴파일이 되는 수준이 아니라, 상위 타입이 정의한 규약(약속)대로 하위 타입이 동작해야 한다는 의미이다. 예를 들어 자동차 인터페이스엑셀은 앞으로 가속하는 기능이라는 규약이 있다면, 이를 구현한 자동차 구현체가 뒤로 가도록 만들면 LSP를 위반한 것이다. 컴파일 성공 여부와 무관하게 다형성이 제대로 동작하려면 이 원칙을 지켜야 한다.

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

범용적인 하나의 인터페이스보다, 클라이언트에 특화된 여러 개의 인터페이스로 분리하는 것이 낫다.

자동차 인터페이스를 운전 인터페이스정비 인터페이스로 분리하면, 정비사는 정비 인터페이스에만, 운전자는 운전 인터페이스에만 의존하게 된다. 이렇게 인터페이스가 명확히 분리되어 있으면, 정비 인터페이스가 변경되어도 운전자에게 영향을 주지 않는 등 영향력을 최소화할 수 있다.

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

구현 클래스가 아니라 인터페이스(추상화)에 의존해야 한다.

클라이언트 코드가 인터페이스뿐 아니라 구현 클래스까지 동시에 의존하면, 실제로는 다형성을 사용하더라도 구현체를 자유롭게 변경할 수 없다.

1
2
3
4
// DIP 위반: 인터페이스와 구현 클래스에 동시에 의존
public class MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();
}
1
2
3
4
5
6
7
8
// DIP 준수: 인터페이스에만 의존, 구현체는 외부에서 주입
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

두 번째 코드처럼 구현체를 외부에서 주입받으면, MemberService는 오직 MemberRepository 인터페이스에만 의존하게 되어 OCP와 DIP를 동시에 만족한다. 그리고 이 “외부에서 객체를 생성하고 연결해주는 역할”을 스프링 컨테이너가 대신 수행해준다.


5. SOLID 원칙 요약

원칙이름핵심 내용
SRP단일 책임 원칙한 클래스는 하나의 책임(변경 사유)만 가져야 한다
OCP개방-폐쇄 원칙확장에는 열려 있고, 변경에는 닫혀 있어야 한다
LSP리스코프 치환 원칙하위 타입은 상위 타입의 규약대로 동작해야 한다
ISP인터페이스 분리 원칙범용 인터페이스보다 특화된 여러 인터페이스가 낫다
DIP의존관계 역전 원칙구현이 아닌 추상화(인터페이스)에 의존해야 한다

6. 스프링과 SOLID의 연결고리

문제는 OCP와 DIP를 지키려고 인터페이스에만 의존하도록 코드를 작성해도, 결국 누군가는 실제 구현 객체를 생성해서 연결(조립)해야 한다는 점이다. 이 역할을 클라이언트가 직접 맡으면 다시 구현 클래스에 의존하게 되어 원칙이 깨진다.

스프링의 DI(Dependency Injection) 컨테이너는 바로 이 “구현 객체를 생성하고 클라이언트에 주입하는 역할”을 애플리케이션 코드 대신 담당해준다. 그 결과 개발자는 인터페이스(역할)에만 의존하는 코드를 작성하고, 실제 구현체 조립은 스프링에 맡길 수 있다. 즉, 스프링은 SOLID 원칙, 특히 OCP와 DIP를 실무에서 현실적으로 지킬 수 있게 해주는 프레임워크라고 할 수 있다.


7. 정리

좋은 객체 지향 설계란 결국 변경에 유연하게 대응할 수 있는 설계이며, 그 핵심 수단은 다형성이다. SOLID 원칙은 이 다형성을 효과적으로 활용하기 위한 구체적인 지침이고, 그중에서도 OCP와 DIP는 “인터페이스에 의존하되 구현체 조립은 외부에 맡긴다”는 하나의 흐름으로 이어진다. 스프링의 DI 컨테이너는 바로 이 흐름을 프레임워크 차원에서 지원하는 도구이며 IoC 컨테이너, 스프링 빈, 의존관계 자동 주입 등은 모두 이 원칙을 실현하기 위한 구체적인 메커니즘이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.