필자가 Java 그리고 Spring 프레임워크를 공부할 때 빠지지 않는 단어들 중 가장많이 나오는 OOP, DI, IoC, SOLID에 대해 하나의 글로 정리해보았다.
OOP (Object-oriented Programming)
객체지향이란 뜻으로, 우리는 Java는 객체지향형 언어이다 라는 말은 많이 듣는다.
객체지향은 말그대로 객체를 단위로 상황 또는 물건을 파악하여 재구성하는 뜻이다. 프로그래밍에서는 결국, 개발하는 프로그램의 로직, 코드를 객체 단위로 나누어 구성하는게 목표라고 생각한다.
현실에서의 관념과 컴퓨터 구조를 반영한 객체지향의 단위와 여러 특징이 생겨났고, 이를 통해 보다 견고한 소프트웨어 개발이 가능해졌다.
재미난 점은 우리가 사용하는 여러 프로그래밍 언어 중 대다수가 OOP 성격을 가진다. 대표적인 Java부터 C#,Python, PHP, Ruby, TypeScript까지 현대에 사용되는 여러 언어 모두 OOP 언어라는 점에서 객체지향은 개발과 뗄 수 없는 개념이 아닐까..
그럼 OOP에 대해 알아보자.
1. class와 object의 구성단위로 작동한다
워낙 클래스와 객체 이 둘 간의 사이는 설명보다 이미지로 설명하는게 좋아보여 구글링해서 나온 이미지로 설명을 생략한다.
class : 강아지
-> property, method 로 여러 객체가 공통적으로 가질 기능을 선언한 틀.
object : 말티즈, 웰시코기, 시바 등
-> class를 기반으로 위 property, method를 가지며 생성된 실물.
2. OOP 특징(Principle)을 가진다
특징이라고 하지만, 결국 클래스와 그 속의 속성/메소드의 특징으로 이해하면 편하다.
Abstraction(추상화)
= 여러 클래스의 공통적인 속성(property)과 기능(method)을 도출하여 하나의 추상클래스로 묶어 표현한다.
Encapsulation(캡슐화)
= 은닉화. 실제 구현 부분을 외부에서 접근하지 못하도록 데이터(속성) 등은 함수를 통해 간접적으로 접근하도록 제한한다.
내부 데이터는 getter/setter + 비즈니스로직 메소드 등으로 접근하도록 하며,
각 클래스, 변수, 메소드는 public(외부)/protected(상속)/private(내부) 등 접근제어자로 제한.
Inheritance(상속)
= 자식 클래스가 부모 클래스를 상속받아, 그 속성과 기능 모두 물려받으며 필요시 일부분을 변경/수정하여 사용한다.
캡슐화를 유지할 수 있으며, 클래스의 재사용성이 늘어난다.
Polymorphism(다형성)
= 변수, 메소드가 상황에 따라 서로 다른 처리와 결과를 내도록 한다.
오버로딩(overloading), 오버라이딩(overriding)으로 필요시 수정을 통해, 서로 다른 결과를 내도록 만들 수 있다.
DI (Dependency Injection)
의존성주입이라고 부르며, 이는 의존성을 수신자 클래스 내부에서 초기화하지 않고, 필요한 시점에 의존성을 전달(주입)한다는 뜻이다.
코드를 통해 이해해보자.
Main에서는 School 객체를 생성하여 giveStudentGrade 로 A+ 를 부여한다. School에서 WiggleJi라는 이름을 가진 Student 객체를 생성하여 전달받은 성적(A+)을 학생에게 전달하고, 학생은 자신의 이름과 성적을 반환하여 출력한다.
이 과정에서 Main -> School -> Student 순으로 객체가 생성되며 School에서 Student 의 메소드를 호출하기 위해 의존성 주입이 필요하며, 위 코드에서는 School 클래스 속에서 Student 객체를 생성하며 의존성 주입을 진행한다.
위 코드의 단점은, Student 객체가 School 클래스 안에서 생성되도록 지정되어 있기 때문에, 학생이름을 수정하려면 School 클래스 코드를 수정해야 한다. 이렇게 요소간 의존성을 의미하는 coupling은 정도에 따라 긴밀한 결합(Tight Coupling) / 느슨한 결합(Loose Coupling) 으로 표현하며 위 코드는 Tight Coupling에 좀 더 가까운 구조이다.
이를 해결하기 위해, 두 클래스간 decoupling 작업을 진행해보자.
School 안에서 Student의 객체를 직접 만드는 부분을 클래스 외부에서 주입시키도록(DI) 수정하면 된다.
이러한 주입 방식에는 생성자주입(Constructor Injection) / Setter주입(Setter Injection) 이 있다.
생성자주입 (Constructor Injection)
Setter주입 (Setter Injection)
위 코드의 가장 큰 차이점은
생성자주입은 Student 객체를 먼저 생성 후, School 객체를 생성할 때 같이 지정된다.
Setter주입은 School 객체를 만든 후, 원하는 시점에서 setStudent setter로 Student 객체를 설정하여 지정한다.
Setter주입은 setter 메소드로 원하는 시점에 필요한 의존성을 넣을 수 있는 장점이 있지만, 이는 오히려 치명적인 오류가 발생할 수 있는 원인이 된다.
만약, School 객체 생성 후 Student 객체 설정 없이 giveStudentGrade를 호출하면, School 속 Student이 존재하지 않기 때문에 이에 대한 NPE(NullPointException)이 발생할 것이다.
반대로, 생성자주입 시 School 객체 초기화 과정에서 Student 객체를 전달하지 않으면 School 객체 생성 과정에서 컴파일러가 의존성 주입을 위한 Student 객체를 전달하도록 요구할 것이다.
즉, School 객체를 생성할 때 Student 객체를 주입시켜야만 하고, 이 덕분에 의존성을 가진 2개의 클래스 모두 안전하게 생성할 수 있다.
사실, 생성자주입/Setter주입 모두 우리가 필요한 의존성을 주입할 수 있는 공통점이 있으며 이는 의존성이 정해진 실서비스 뿐 아니라 테스트코드 또는 다른 로직에서도 필요시 다른 의존성 객체를 전달할 수 있다.
ex) 금리 서비스에서 이자로 복리/단리를 교체해야할 경우,
계좌 클래스에서 이자 클래스를 주입할 때 복리/단리 로 수정하여 보다 빠른 교체와 유지보수가 가능하다.
이외에 Spring의 @Autowired annotation으로 가능한 Field Injection이 있지만..
실무에서는 Setter Injection보다 Constructor Injection을 많이 사용한다.
위와 같이 의존성 주입을 사용하면 가질 수 있는 장점으로
1. 객체 간 결합도를 낮출 수 있다 (응집도와 다름!)
2. 코드의 재사용성이 높아지며, 가독성이 좋아진다.
4. 테스트하기 좋은 코드를 만들 수 있다.
IoC(Inversion of Control)
개발 관점에서 IoC는 프로그램의 제어권이 역전됐다는 의미이며,
프로그래머가 작성한 프로그램이 제어권을 가지는 것이 아닌, 프레임워크/서비스 또는 다른 컴포넌트와 같은 외부 소스가 프로그래머가 작성한 프로그램을 제어하는 원칙을 말한다.
IoC 개념은 Designing Reusable Classes 에서 처음 등장
IoC는 컴포넌트간 의존관계 결정/설정과 생명주기까지 관리하도록 설계되었으며, Spring 에서는 이러한 기능을 내장한 IoC 컨테이너를 제공한다.
1. 객체의 생성을 책임지고, 의존성 관리한다.
2. POJO(Plain Old Java Object: 일반 자바객체)의 생성/초기화/서비스/소멸 권한을 책임진다.
3. 필요의 경우 컨테이너가 아닌 개발자가 직접 생성가능하도록 설정되어있다.
IoC의 분류는 위와 같은데, 점점 내용이 복잡해지는 것 같아 좀 더 쉽게 설명해본다.
IoC
= 프로그램의 제어권을 개발자가 아닌 프레임워크에 위임.
이를 위해 위와 같이 이벤트를 발생시키거나 의존성주입(생성자주입/Setter주입)을 통해 프레임워크에게 위임하도록 미리 설정
DI
= 하나의 클래스에서 다른 클래스의 의존성을 가질경우(School&Student 예시),
클래스 내에서 선언을 통해 정적으로 설정하는 것이 아닌 외부에서 주입을 통해 동적으로 필요한 의존성을 설정해두는 것
이를 실무 내용에 대입하여 정리하면,
웹서비스에 필요한 서비스로직과 API 구조, 각종설정(외부 서비스 연동 / 서비스 정책 등)을 설계하여 적절한 클래스 객체 단위로 나누어 코드를 작성하고, 각 클래스간의 의존성을 DI로 성립시킨다. 이렇게 생성된 코드는 Spring 프레임워크가 제어하여 웹서비스 어플리케이션을 실행하고 이를 IoC라고 부른다.
Spring에서는 IoC를 위한 의존성주입(DI)을 DI 컨테이너(IoC 컨테이너)로 관리하며 이렇게 관리되는 객체를 Bean 이라고 부른다.
이에 대한 설명은 스프링 컨테이너에 대한 글 [별도로 제공예정] 에서 확인하자.
SOLID 원칙
앞서 얘기한 객체지향 설계(OOP)의 내용에 속하지만, 이 원칙을 설명하기 이전에 IoC, DI를 먼저 인지해야 이 원칙을 이해하는데 도움이 될 것 같아 후순위로 설명한다.
- SRP (Single Responsibility Principle)
- LSP (Liskov Substitution Principle)
- ISP (Interface Segregation Principle)
- DIP (Dependency Inversion Principle)
SOLID는 객체 지향을 올바르게 설계하도록 도와주는 원칙의 첫글자를 모아 정의한 단어이며
앞서 얘기한 객체지향 특징 4가지(추상화, 캡슐화, 상속, 다형성)과는 별개이다.
높은 응집도(high cohesion)와 낮은 결합도(loose coupling)를 위한 원칙을 객체지향 관점에서 적용한 것이고,
객체지향 언어를 주로 사용하는 개발자 면접에서(대부분 Java) 필수로 등장하는 원칙이라고 생각한다.
1. SRP (Single Responsibility Principle) : 단일 책임 원칙
= 하나의 클래스는 하나의 책임을 가져야한다.
각 클래스를 설계할 때 서로 간 경계를 정하고, 각 클래스에 맞는 속성과 메서드를 선택하여 설계해야한다.
위 School/Student 예제를 생각하여 클래스를 구현하면, 아래와 같이 나눌 수 있다.
School은 학교의 기능을 위한 속성/메서드를, Student는 학생의 기능을 위한 속성/메소드를 가진다.
즉, 각 클래스와 관련된 역할과 책임만을 부여하여 구성해야한다는 뜻. 나아가 모듈, 프레임워크까지 단일 책임을 적용하여, 각 컴포넌트가 독립적으로 각자의 역할을 가지도록 설계하는게 SRP이다.
2. OCP (Open-Closed Principle) : 개방-폐쇄 원칙
= 소프트웨어 요소의 확장은 열려있고, 변경에 대해서는 닫혀 있어야 한다.
한 회사에 2개의 컴퓨터 게임 제품이 있다고 하자. 두 게임 모두 같은 장르(RPG)이지만, 하나는 MMO이고 다른 하나는 싱글플레이 게임이다.
이를 객체로 구성한다면, 2개의 게임 클래스를 만들어 하나는 RPG/MMO 에 맞는 속성과 메서드를 가지고 다른 하나는 RPG/싱글플레이에 맞게 설계할 것이다.
OS에서 위 게임들이 실행되려면 각각의 클래스마다 의존성을 가지는 구조이기 때문에
2 게임 개발 이후 다른 게임을 만들 경우, 또 다른 클래스를 정의해야한다.
이 경우, 결합도가 높아져 확장성에 어려움을 가지고 유지보수면에서도 불편해질 수 있다.
(언제까지나 이해를 위한 예시이니 진지해지지 말자)
이를 OCP에 의거하여
게임마다 특징은 다르지만 공통적인 요소를 고려하고, 모든 게임을 상위클래스/인터페이스를 중간에 두어 설계한다면 아래와 같은 구조를 가질 수 있다.
위처럼 구현하게 되면 OS는 GameA, GameB, GameC ... 등 각 게임을 접근하지 않고 Game 인터페이스를 바라보면,
Game 인터페이스를 implements 하고 있는 클래스에 대한 접근이 훨씬 유연해진다.
또한, Game 인터페이스를 implements 하는 클래스들 또한, 공통적인 속성/메소드는 미리 지정되어 있기 때문에 주어진 틀에 맞게 설계하고 필요한 경우 오버라이딩/오버로딩 또는 새로운 속성/메소드 추가로 확장도 용이해진다.
무엇보다 OS와 Game 간 관계에 대해 수정 없이 작동할 수 있어 유지보수에 유리하다.
OCP는 확장에 개방적이고, 수정에 폐쇄적!
3. LSP (Liskov Substitution Principle) : 리스코프 치환 원칙
= 서브 타입은 언제든 자신의 상위 기반 타입으로 교체할 수 있어야한다.
LSP에 의거하여
우리는 인터페이스와 클래스를 사용할 때 서로 간 상위/하위 관계를 고려하여 설계해야 한다.
위의 OS/Game 예시에서 여러 Game을 하나의 Interface와 이를 상속받는 여러개의 클래스로 설계해보았다.
상위/하위 클래스의 관계에서 제일 중요한건 하위 클래스는 상위 클래스의 유형이다.
하위 클래스는 상위 클래스를 상속하며, 우리는 확장을 위해 상속을 받는다.
위에서 OS가 GameA 대신 상위개념인 Game을 바라볼 수 있던 이유도
GameA가 Game을 상속받아 구현되기 때문이다.
다른 예시로 승용차/트럭 관계를 보면,
트럭과 승용차 모두 바퀴와 달리는 기능을 가지지만, 승용차는 사람을 태우는 행위에 집중하고, 트럭은 짐을 실어나르는 역할을 가진다.
둘 다 실제 속성/메소드를 갖는 클래스이기에 트럭은 승용차를 대신할 수 없고, 반대로 승용차도 트럭을 대신할 수 없다.
이를 해결하려면 자동차 -> 승용차/트럭 관계로 가야한다.
자동차는 달리는 행위와 바퀴의 개념을 가지고, 승용차/트럭은 자동차를 상속받기 때문에
두 클래스 모두 자동차로 대시할 수 있다.
LSP는 상위 클래스는 하위 클래스를 대신하여 교체될 수 있다는 뜻이다.
이는 다른말로 하위클래스가 상위클래스를 대신할 때 논리적으로 맞는 구조를 가져야한다는 말이다.
4. ISP (Interface Segregation Principle) : 인터페이스 분리 원칙
= 클라이언트는 자신이 사용하지 않는 메서드에 의존관게를 맺으면 안된다.
ISP는 SRP와 같은 원인에 대한 다른 해결책을 제시하는 원칙이다.
하나의 클래스에서 여러 책임과 메서드가 구현되어 있는 경우를 분리할 때,
SRP는 여러 클래스로 나누어 구현한다.
ISP는 해당 클래스 입장에서 사용하는 기능만 제공하도록 하나의 범용적인 인터페이스가 아닌
클라이언트에 특화된 여러개의 인터페이스로 분리하여 구현해야 한다.
예를 들어, 컴퓨터의 기능을 ISP에 적용해보자.
ISP 적용 전
ISP 적용 후
하나의 인터페이스에서 컴퓨터에 필요한 CPU, GPU, HDD에 대한 기능을 모두 정의하고 받게될 경우, GPU가 없는 컴퓨터를 만들 수 없다. Java에서 인터페이스에서 정의한 기능은 optional 하지 않기 때문에 이를 implement할 경우 필수로 정의해줘야 하기 때문!
ISP를 적용한 경우, CPU/GPU/HDD에 대한 인터페이스를 분리하였기 때문에 클라이언트는 생성할 컴퓨터의 스펙에 따라 최소한의 요구에만 맞게 각 인터페이스를 implement하여 구현할 수 있다.
인터페이스 분리의 장점은 모든 클라이언트가 각자의 필요 영역에 해당하는 기능들만 최소한으로 접근하고 그외 불필요한 기능은 간섭없이 유지할 수 있다. 이렇게 인터페이스를 통해 메소드를 제공할 때 최소한의 메소드만 제공하라는 원칙이 인터페이스 최소주의 원칙이다.
SRP와 ISP가 같은 범주의 원칙처럼 느껴지지만,
SRP는 각 모듈이 하나의 역할/책임을 가지는데 중점을 두며, 다중 클래스 상속이 존재하지 않기 때문에 원칙을 지키기에 언어구조 상 조금 더 엄격한 조건을 가진다.
반대로 ISP는 하나의 범용적인 인터페이스로 제공을 하게되면 클라이언트 입장에서 구현할 부분과 불필요한 부분이 많아져 이를 좀 더 작은 단위로 제공하여 불필요한 인터페이스는 구현하지 말자는 취지이다.
ISP는 좀 더 클라이언트의 편의성에 집중적인 원칙이고, 애매모호한 기준점을 가질 수 있기에,
SOLID의 다른 원칙보다 지키기 어려운 원칙이라고 생각한다. (여러 인터페이스로 나눈 뒤, 실제 사용하지 않으면.. 무용지물이다)
5. DIP (Dependency Inversion Principle) : 의존성 역전 원칙
고차원 모듈은 저차원 모듈에 의존적이면 안된다. 두 모듈 모두 추상화 된 것에 의존해야 한다.
추상화 된 것은 구체적인 것에 의존하면 안된다. 구체적인 것이 추상화된 것에 의존해야 한다.
자주 변경되는 구체(concrete) 클래스에 의존하지 마라
- Robert C Martin[CleanCode]
말 그대로, 자신보다 변경되기 쉬운 컴포넌트에 의존하지 말라는 원칙이다.
먼저 상위클래스 또는 추상클래스 같은 상위 레벨에 존재해야할 클래스는 하위클래스나 구체클래스에 의존적이지 않아야 한다.
실제 로직에서 최전방에서 구현되어 실행되는 하위클래스보다 변화가 상대적으로 빈번하게 일어날 수 있다.
반대로 상위클래스와 추상클래스는 보통 좀더 큰 범위에 적용하는 단위이기에
만약 상위클래스가 다른 하위클래스에게 의존관계를 가질 경우,
하위클래스의 변경은 상위클래스에게도 영향이 간다.
이는, 해당 상위클래스 뿐 아니라 상위클래스 아래에 있는 하위클래스들까지 영향을 끼치기 때문에
해당 부분에 대한 모든 수정이 필요하다.
예를 들어, 위 컴퓨터 예제에서
컴퓨터가 AMD CPU에 의존적이게 되면, CPU를 교체하는데 있어 AMD CPU의 속성/메소드에 맞게 수정이 이루어져야 한다.
만약 컴퓨터가 CPU 인터페이스(또는 추상클래스)에 의존적이게 되면, AMD CPU 뿐 아니라 Intel CPU, Apple CPU 등 CPU 의 하위클래스에는 의존적이지 않아 CPU를 교체하는데 있어 최소한의 수정으로 완료할 수 있다.
좀 더 짧게 얘기하면, CPU에 맞는 자리(상위클래스)만 주어지면 어떤 종류의 CPU(구체클래스)가 와도 해당 자리를 교체할 수 있다는 것이다.
고차원 클래스를 자신보다 변하기 쉬운 구체클래스에 의존하던 구조를 추상/상위클래스로 두어 변화에 영향을 받지 않게
의존 방향을 역전시킨 구조로 바꾸었다.
Reference
Image
- https://www.educative.io/answers/what-is-inversion-of-control
- https://www.codeproject.com/Articles/592372/Dependency-Injection-DI-vs-Inversion-of-Control-IO
https://en.wikipedia.org/wiki/SOLID
https://medium.com/groupon-eng/dependency-injection-in-java-9e9438aa55ae
https://stackoverflow.com/questions/6550700/inversion-of-control-vs-dependency-injection
'Java' 카테고리의 다른 글
[Java] JCF(Java Collection Framework) (0) | 2022.07.18 |
---|