1. [스프링 핵심 원리 - 기본편] 객체 지향 설계와 스프링
1. 스프링의 핵심 개념은 무엇인가?
스프링은 자바 언어 기반의 프레임워크이다. 자바의 가장 큰 특징은, 객체 지향 언어라는 것이다. 스프링은 객체 지향 언어가 가진 강력한 특징을 살려내는, 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크다. 그렇다면 좋은 객체 지향 프로그래밍이란 무엇일까?
2. 좋은 객체 지향 프로그래밍이란?
좋은 객체 지향 프로그래밍이란, 객체들을 레고 블럭 조립하듯 유연하고 쉽게 변경할 수 있게 개발하는 것을 말한다. 이를 위해서는 역할과 구현을 명확히 분리해야한다. 자바의 다형성을 활용하면 역할은 인터페이스가 되고, 구현은 인터페이스를 구현한 클래스, 구현 객체가 된다. 이렇게 역할과 구현을 분리하면 다음과 같은 장점이 생긴다.
1) 클라이언트는 대상의 역할(인터페이스)만 알면 된다.
2) 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
3) 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
4) 클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않는다.
프로그램에서 혼자 있는 객체는 없다. 모든 객체는 서로 협력관계에 있으며, 각각의 객체가 클라이언트가 될 수도 있고 서버가 될 수도 있다. 이러한 상황에서 역할과 구현을 분리한다면, 클라이언트를 변경하지 않고 서버의 구현을 유연하게 변경할 수 있게된다. 단 여기에도 한계가 있는데, 만약 역할(인터페이스)가 변한다면 클라이언트, 서버 모두에 큰 변경이 발생한다. 따라서 초기에 인터페이스를 안정적으로 설계하는 것이 중요하다.
3. SOLID
1. SRP (Single Responsibility Principle) : 단일 책임 원칙
한 클래스는 하나의 책임만 가져야 한다는 원칙이다. 이때 하나의 책임이라는 것이 모호할 수 있는데, 변경을 기준으로 하면 된다. 즉, 어떤 클래스를 변경할 때의 이유가 하나뿐이면 된다. SRP는 클래스 레벨에서만이 아니라 메서드 레벨에서도 존재한다. 하나의 메서드는 하나의 역할만 해야 한다.
2. OCP (Open Closed Principle) : 개방 폐쇄 원칙
소프트웨어의 요소는 확장에는 열려있으나 변경에는 닫혀있어야한다는 원칙이다. 이는 다형성을 통해 가능해지는데, 인터페이스와 구현 클래스를 분리하여 개발한다면, 구현 클래스의 내부 구조가 변경되거나 구현 클래스 자체가 변경되는 경우에도 소스 코드 변경을 하지 않을 수 있다. 즉 인터페이스 확장에는 열려있지만, 코드 변경에는 닫혀있어야 한다.
3. LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
프로그램의 객체는 프로그램의 정합성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다는 원칙이다. 예를 들면 자동차라는 인터페이스가 있고 거기에 액셀()이라는 메서드가 있다면, 그 메서드는 자동차가 앞으로 가도록 구현되어야 한다. 만약 새로운 구현 클래스에서 액셀() 메서드가 자동차가 뒤로 가도록 구현이 된다면, 컴파일에는 문제가 없겠지만 새로운 구현 클래스로 치환 시, 프로그램에 문제가 생길 것이다.
4. ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
특정 클라이언트를 위한 인터페이스 여러개가 범용 인터페이스 하나보다 낫다는 원칙이다. 예를 들면 자동차라는 하나의 인터페이스보단, 그 인터페이스를 운전자와 정비라는 2개의 인터페이스로 나누는 것이 더 낫다는 것이다. 그렇게되면 향후 정비 인터페이스가 변경된다고 하더라도 운전자 인터페이스에는 영향을 주지 않게되고, 각 인터페이스의 역할이 좀 더 명확해지며 재사용성도 높아진다. 이 원칙은 SRP의 구체화된 원칙이라고 봐도 될 것 같다.
5. DIP(Dependency Inversion Principle) : 의존 역전 원칙
프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다는 원칙이다. 즉 인터페이스에 의존해야지, 구현 클래스에 의존해선 안된다.
4. DIP, OCP
객체 지향의 핵심은 결국 다형성이다. 하지만 다형성만으로는 DIP, OCP를 지킬 수 없다. 아래 예시를 보자. MemberRepository와 MemberService라는 인터페이스를 통해 역할을 나타내고, MemoryMemberRepository와 MemberServiceImpl을 통해 구현을 나타냈다. 역할과 구현이 적절히 분리되어 객체 지향적으로 설계가 된 것 같지만, 앞서 말한 것과 같이 이 코드는 DIP와 OCP에 위배되는 코드이다.
public interface MemberRepository {
void save(Member member);
Member findById(Long memberId);
}
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
@Override
public void save(final Member member) {
store.put(member.getId(), member);
}
@Override
public Member findById(final Long memberId) {
return store.get(memberId);
}
}
public interface MemberService {
void join(Member member);
Member findMember(Long memberId);
}
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
@Override
public void join(final Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(final Long memberId) {
return memberRepository.findById(memberId);
}
}
아래에서 MemberServiceImpl만 따로 다시 보도록 하자. MemberRepository라는 인터페이스를 필드로 가지고 있어 추상화만 의존한다고 생각할 수도 있지만, 구현 객체 생성 또한 이 클래스에서 하고 있기 때문에 구체 클래스에도 의존한다고 볼 수 있다. 이는 DIP 위반이다. 또한 만약 MemoryMemberRepository를 DbMemberRepository라는 다른 구체 클래스로 변경해야하는 상황이 생겼다고 가정해보자. 그렇다면 코드를 변경해야하는데, 이는 확장에는 열려있고 변경에는 닫혀있어야 한다는 OCP에 위배되는 것이다.
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
@Override
public void join(final Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(final Long memberId) {
return memberRepository.findById(memberId);
}
}
public class MemberServiceImpl implements MemberService {
// private final MemberRepository memberRepository = new MemoryMemberRepository();
private final MemberRepository memberRepository = new DbMemberRepository();
}
5. 관심사의 분리
위의 문제는 '사용 영역'과 '구성 영역'을 분리함으로써 해결이 가능하다. 먼저 MemberServiceImpl에서 MemberRepository의 객체를 생성하지 않고 생성자를 통해 주입받도록 아래와 같이 수정하도록 한다. 이 코드는 구체 클래스에는 의존하지 않기 때문에 DIP를 만족한다. 또한 다른 객체로 MemberRepository가 변경된다고 하더라도 변경이 일어나지 않으므로 OCP도 만족한다.
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
public MemberServiceImpl(final MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public void join(final Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(final Long memberId) {
return memberRepository.findById(memberId);
}
}
대신 AppConfig이라는 별도의 클래스를 생성하여 어떤 클래스의 객체를 사용할지를 결정하는 '구성 영역'을 아래와 같이 만든다. 이로써 구현 클래스는 자신의 로직에만 집중하면 되고, 객체의 구성은 '구성 영역'을 담당하는 AppConfig이 담당하게 된다. 이는 SRP를 지켰다고 볼 수 있다.
public class AppConfig {
private MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
}
MemberService를 사용하는 클라이언트에서는 아래와 같이 사용하면 된다. 이 코드 역시 DIP와 OCP를 만족한다.
public class MemberApp {
public static void main(String[] args) {
AppConfig appConfig = new AppConfig();
MemberService memberService = appConfig.memberService();
final Member member = new Member(1L, "memberA", Grade.VIP);
memberService.join(member);
}
}
6. IoC, DI, 그리고 컨테이너
1) 제어의 역전 IoC(Inversion of Control)
위에서 살펴본 코드에서 AppConfig은 프로그램에 대한 제어 흐름에 대한 모든 권한을 가지게 된다. 즉, 어떤 구현 객체를 생성하고 연결할지는 AppConfig이 담당하게 되고, MemberServiceImpl과 같은 구현 객체는 자신의 로직을 실행하는 역할만 하게 된다. MemberServiceImpl은 MemberRepository라는 인터페이스를 호출하지만, 실제로 어떤 구현 객체들이 실행될지는 AppConfig이 결정하게 된다. 심지어 MemberServiceImpl 객체 생성도 AppConfig이 담당한다. MemberService 인터페이스를 호출하는 MemberApp의 경우 어떤 구현 객체가 실행될지 알 수 없다. 이처럼 프로그램의 제어의 흐름을 구현 객체가 직접 담당하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(IoC)라고 한다.
* 프레임워크 vs 라이브러리
- 내가 작성한 코드를 제어하고 대신 실행한다면, 그것은 프레임워크다. (ex: Spring, JUnit)
- 반면 내가 작성한 코드가 직접 제어의 흐름을 담당한다면, 그것은 프레임워크가 아니라 라이브러리다. (ex: Gson, Apache poi)
2) 의존 관계 주입 DI(Dependency Injection)
의존 관계는 '정적인 클래스 의존 관계'와 '실행 시점에서 결정되는 동적인 객체 의존 관계' 둘로 분리해서 생각해야 한다.
- 정적인 클래스 의존 관계 : 클래스가 사용하는 import 코드만 보고 쉽게 의존 관계를 판단할 수 있다.
- 동적인 객체 의존 관계 : 애플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존 관계다.
의존 관계 주입(Dependency Injection)이란, 애플리케이션 실행시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달하여 의존 관계를 연결해주는 것을 말한다. 앞서 살펴본 코드에선 AppConfig이 이 역할을 하였다. 이처럼 의존 관계 주입을 사용하면, 클라이언트 코드를 변경하지 않고, 클라이언트의 객체 의존 관계를 쉽게 변경할 수 있다.
3) IoC 컨테이너, DI 컨테이너
AppConfig 처럼 객체를 생성하고 관리하면서 의존 관계를 연결해주는 것을 IoC 컨테이너 혹은 DI 컨테이너라고 한다. 스프링은 AppConfig이 하는 역할을 좀 더 편리하게 해주는 프레임워크라고 할 수 있다.
IoC의 경우 제어의 역전에만 초점이 맞춰져 있는 용어이기 때문에 의존 관계를 연결해주는 스프링 컨테이너를 명확히 설명해주지 못하는 경향이 있다고 판단되어 최근에는 DI 컨테이너라는 용어를 더 많이 쓴다고 한다.