개요
오늘은 자바에서 가장 중요한 개념중 하나인 객체 지향을 정리 하려 한다.
이 글은 순수자바로만 객체 지향 프로그래밍을 보여주는 글이다.
우선 객체 지향이란 무엇일까?
아마 처음 자바를 배울 때 가장 먼저 만나는 개념일 것이다.
모든 강의에서 처음부터 강조하는 개념이니..
객체 지향 프로그램이란?
프로그램을 단순히 명령어의 나열로 보는게 아닌 객체의 관점에서 프로그래밍한다는 것이다.
프로그래밍에서 필요한 데이터를 추상화시켜 상태와 행위를 가진 객체를 만들고 그 객체들 간의 유기적인 상호작용을 통해 로직을 구성하는 프로그래밍 방법이다.
객체지향 프로그램은 프로그램을 유연하고 변경에 용이하게 만들어야 한다.
유연하고 변경에 용이한 프로그램?
마우스나 키보드를 변경하는 것 처럼 코드를 쉽고 유연하게 변경하면서 개발할 수 있는 프로그램을 말한다.
말로는 쉽지만 실제로 코드를 짜다보면 하나의 클래스에 여러 클래스들이 묶여 있는걸 볼 수 있다.
그래서 하나를 수정하다보면 어느새 다른 코드에 손대고 있는 상황은 자주 일어난다.
유연하고 변경에 용이한 프로그램을 짜려면 어떻게 해야할까?
클린코드의 저자 로버트 C 마틴은 5가지의 원칙을 명명했는데 이 원칙을 SOLID원칙이라 한다.
5가지 원칙의 앞자만 따와 SOLID 원칙이라 부른다.
객체 지향 설계의 5가지 원칙(SOLID)?
- SRP(Single Responsibility Principle) : 단일 책임 원칙
- OCP(Open Closed Priciple): 개방 폐쇄 원칙
- LSP(Listov Substitution Priciple): 리스코프 치환 원칙
- ISP(Interface Segregation Principle): 인터페이스 분리 원칙
- DIP(Dependency Inversion Principle): 의존 역전 원칙
SRP(Single Responsibility Principle) : 단일 책임 원칙
객체는 단 하나의 책임만 가져야 한다.
여기서 ‘책임’은 기능을 말한다.
OCP(Open Closed Priciple): 개방 폐쇄 원칙
확장에는 열려있고, 수정에는 닫혀 있어야 한다.
(추상화, 다형성)
LSP(Listov Substitution Priciple): 리스코프 치환 원칙
서브타입은 언제나 부모타입으로 교체할 수 있어야 한다.
업스캐팅 되어도 부모의 메서드를 사용해도 동작되도록 해야한다.
(다형성)
ISP(Interface Segregation Principle): 인터페이스 분리 원칙
인터페이스를 사용에 맞게 분리해야한다.
한번 인터페이스를 생성하면 후에 수정사항이 생겨 인터페이스를 분리하는 행위가 없도록 해야한다.
DIP(Dependency Inversion Principle): 의존 역전 원칙
Class를 직접 참조하는 것이 아닌 추상클래스 or 인터페이스로 참조해야한다.
SOLID 객체 지향 원칙을 적용하면 코드를 확장하고 유지 보수 관리하기가 더 쉬워지며, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄임으로써 프로젝트 개발의 생산성을 높일 수 있다
구현
자 이제 코드에 입혀보자.
내가 짠 코드들이 과연 객체지향설계의 5가지 원칙(SOLID)를 잘 지켜 좋은 설계를 했는지 알아보자.
순수 자바로만 이루어진 아주 간단한 회원가입 서비스이다.
데이터베이스 대신 Map으로 구현했다.
Member클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Member{
private Long id;
private String name
}
public Member(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
repository클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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(Member member){
store.put(member.getId(),member);
}
@override
public Member findById(long MemberId){
return store.get(memberId);
}
}
MemoryMemberRepository
는 MemberRepository
를 구현하고있다.
단순히 Member객체를 저장하고 꺼내오는 메서드만 구현했다.
service클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface MemverService{
void join(Member member);
Member findMember(long memberId);
}
public class MemberServiceImp implements MemberService{
MemberRepository memberRepository = new MemoryMemberRepository();
@override
public void join(Member member){
memberRepository.save(member);
}
@override
public Member findMember(long memberId){
memberRepository.findById(memberId);
}
MemberServiceImp
은 MemberService
를 구현했다.
MemoryMemberRepository
를 참조해 서비스코드를 작성했다.
이제 테스트클래스에서 member를 생성해 회원 가입하고 조회를 해보자.
Test코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MemberServiceImpTest {
MemberService memberService = new MemberServiceImp();
@Test
void join() {
Member member = new Member(1L, "김인엽");
memberService.join(member);
Member findMember = memberService.findMember(1L);
Assertions.assertThat(member).isEqualTo(findMember); // 같은 객체인지 확인
}
}
저장한 객체와 찾아온 객체가 같은 객체인게 확인되었다.
자 이렇게 순수 자바만으로 아주 간단한 회원가입과 조회를 구현했다.
과연 내가 짠 코드는 SOLID 원칙을 잘 지켰을까?
한번 확인해보자.
❗OCP 위반 : “소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”
1
2
3
4
5
public class MemberServiceImp implements MemberService{
// MemberRepository memberRepository = new MemoryMemberRepository();
MemberRepository memberRepository = new DbMemberRepository();
...
}
만약 데이터베이스를 구축하는 변경이 있을 경우 Service단의 MemberRepository
의 구현체를 MemoryMemberRepository
에서 DbMemberRepository
로 변경해야한다.
그럼 MemberServiceImp
의 코드를 변경해야 한다.
repository를 변경하는데 service단 까지 변경이 이루어져야 한다.
❗ DIP 위반 : “추상화에 의존해야지, 구체화에 의존해선 안된다.”
1
2
3
4
public class MemberServiceImp implements MemberService{
MemberRepository memberRepository = new MemoryMemberRepository();
...
}
MemberServiceImp
은 보기엔 인터페이스인 MemberRepository
에 의존해 DIP를 잘 지킨것 같지만 뒤에 구현체인 MemoryMemberRepository
까지 의존했다.
그럼 구현체를 빼고 인터페이스만 의존하도록 변경해보자.
1
2
3
public class MemberServiceImp implements MemberService{
MemberRepository memberRepository;
}
DIP를 지키기 위해 인터페이스만 의존하도록 코드를 변경했다. 하지만 구현체가 없기 때문에 실행하면 NPE(null pointer exception)가 발생한다.
이 문제를 해결하기 위해선 누군가 MemberServiceImp
에 MemberRepository
의 구현 객체를 대신 주입해주어야 한다.
관심사 분리하기
역할, 기능에 맞게 분리해서 작성하는 것을 말한다.
외부에서 구현 객체를 생성해서 연결해주면 되지않을까?
AppConfig 생성
구현 객체를 생성하고, 연결하는 책임을 지닌 클래스를 생성했다.
1
2
3
4
public class AppConfig{
public MemberService memberService(){
return new MemberServiceImp(new MemoryMemberRepository());
}
구현객체를 생성하고 생성자를 통해 주입시켜준다.
이대로 실행 하면 MemberServiceImp
의 생성자를 만들어주지 않았기 때문에 오류가 날것이다.
생성자를 만들어주자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MemberServiceImp implements MemberService{
MemberRepository memberRepository;
// 생성자
public MemberService(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
@override
public void join(Member member){
memberRepository.save(member);
}
@override
public Member findMember(long memberId){
memberRepository.findById(memberId);
}
이제 MemberServiceImp
은 MemoryMemberRepository
를 의존하지 않고 오직 MemberRepository
인터페이스만을 의존하게 된다.
MemberServiceImp
는 생성자를 통해 어떤 구현객체가 주입되는지 알수없다.
오직 외부(AppConfig)에서만 알수있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MemberServiceImpTest {
AppConfig appConfig = new AppConfig();
@Test
void join() {
MemberService memberService = appConfig.memberService();
Member member = new Member(1L, "김인엽");
memberService.join(member);
Member findMember = memberService.findMember(1L);
Assertions.assertThat(member).isEqualTo(findMember);
}
}
AppConfig에서 생성한 구현객체를 가져와 연결해주었다.
✅ OCP 해결 : “소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”
MemoryMemberRepository
에서 DbMemberRepository
로 변경할 경우 AppConfig
가 주입하므로 MemberServiceImp
코드를 변경하지 않아도 된다.
이제 Repository에 변경이 있다면 AppConfig에서 변경만 해주면 되기 때문에 Service단은 건드리지 않아도 된다.
✅ DIP 해결 : **“추상화에 의존해야지, 구체화에 의존해선 안된다.”
이제는** MemberServiceImp
가 MemberRepository
의 인터페이스만을 의존하게됐다. 실제 구현체는 AppConfig
가 주입해주게 된다.
IoC
제어의 역전 IoC(Inversion of Control)
AppConfig를 만들기 전의 프로그램은 클라이언트 구현 객체가 스스로 필요한 서버 구현 객체를 생성하고 연결하고 실행했다. 구현 객체가 프로그램의 제어 흐름을 이끌어갔다.
하지만 AppConfig를 만들고 나서부턴 구현객체는 자신의 로직만 수행할 뿐이다. 필요한 구현객체는 AppConfig가 생성해서 연결해주므로 프로그램의 제어 흐름은 이제 AppConfig가 이끌어간다.
이렇게 프로그램의 흐름을 직접 이끌어가는게 아니고 외부에서 이끌어 가는것을 제어의 역전(IoC)라 한다.
DI
의존관계 주입 DI(Dependency Injection)
애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결 되는 것을 의존관계 주입이라 한다.
의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다.
IoC컨테이너, DI 컨테이너
AppConfig 처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 IoC 컨테이너 또는 DI 컨테이너 라고 한다.