GO SOPT 서버파트 1차 세미나(자바의 객체지향 및 서버의 이해) 스스로 학습 키워드 정리입니다.
객체지향 프로그래밍 설계의 5가지 기본 원칙으로, 아래 5가지의 앞글자를 따서 지어진 이름
1️⃣ SRP 단일 책임 원칙
2️⃣ OCP 개방-폐쇄 원칙
3️⃣ LSP 리스코프 치환 원칙
4️⃣ ISP 인터페이스 분리 원칙
5️⃣ DIP 의존관계 역전 원칙
1️⃣ SRP 단일 책임 원칙 (Single responsibility principle)
: 작성된 클래스는 하나의 기능만 가지며 클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하는 데 집중되어 있어야 한다는 원칙.
하나의 책임이라는 것은 클 수도 있고 작을 수도 있으며, 문맥과 상황에 따라서 다름. 중요한 기준은 변경으로, 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이라고 할 수 있음. 이를 지키지 않으면 한 책임의 변경에 의해 다른 책임과 관련된 코드에 영향을 미칠 수 있는데, 유지보수가 매우 비효율적.
2️⃣ OCP 개방-폐쇄 원칙 (Open/closed principle)
소프트웨어 요소는 확장에는 열려있어야 하고, 변경에는 닫혀있어야 함. 즉 기존의 코드를 변경하지 않고 기능을 수정하거나 추가할 수 있도록 설계해야 함.
OCP 는 추상화 (인터페이스) 와 상속 (다형성) 등을 통해 구현해낼 수 있음. 자주 변화하는 부분을 추상화함으로써 기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 함으로써 유연함을 높이는 것이 핵심임.
public class MemberService {
// private MemberRepository memberRepository = new MemoryMemberRepository();
private MemberRepository memberRepository = new JdbcMemberRepository();
}
구현 객체를 변경하려면 클라이언트 코드를 변경해야 하는데, 분명 다형성을 사용했지만 OCP 원칙을 지킬 수 없음. 이 문제를 객체를 생성하고, 연관관계를 맺어주는 별도의 조립, 설정자를 도입하는 방법으로 (Spring 컨테이너와 같은) 해결할 수 있음.
3️⃣ LSP 리스코프 치환 원칙 (Liskov substitution principle)
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다
다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것으로, 다형성을 지원하기 위한 원칙. 인터페이스를 구현한 구현체는 믿고 사용하려면, 이 원칙이 필요함.
LSP는 OOP의 기본 기능인 상속 개념과 밀접한 관련이 있음. 상속을 통해 하위 클래스는 부모 클래스에서 속성과 메서드를 상속할 수 있으므로 코드 재사용이 가능하고 모듈성이 향상됨.
결국은 LSP를 지키지 않으면 개방 폐쇄 원칙을 위반하게 되는 것이며, 기능 확장을 위해 기존의 코드를 여러 번 수정해야 할 것임. 따라서 상속 관계를 잘 정의하여 LSP 원칙이 위배되지 않도록 설계해야 함.
4️⃣ ISP 인터페이스 분리 원칙 (Interface segregation principle)
클라이언트는 자신이 사용하는 메소드에만 의존해야 한다는 원칙. 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 않아야 함.
하나의 통상적인 인터페이스보다는 차라리 여러 개의 세부적인 (구체적인) 인터페이스가 나음.
각 클라이언트가 필요로 하는 인터페이스들을 분리함으로써, 클라이언트가 사용하지 않는 인터페이스에 변경이 발생하더라도 영향을 받지 않도록 만들어야 하는 것이 핵심.
(예시)
자동차 인터페이스 -> 운전 인터페이스, 정비 인터페이스로 분리
사용자 클라이언트 -> 운전자 클라이언트, 정비사 클라이언트로 분리
분리하면 정비 인터페이스 자체가 변해도 운전자 클라이언트에 영향을 주지 않음. 인터페이스가 명확해지고, 대체 가능성이 높아짐.
5️⃣ DIP 의존관계 역전 원칙 (Dependency inversion principle)
의존 관계를 맺을 때, 변하기 쉬운 것 (구체적인 것) 보다는 변하기 어려운 것 (추상적인 것)에 의존해야 함.
구체화된 클래스(구체화)에 의존하기 보다는 추상 클래스나 인터페이스(추상화)에 의존해야 한다는 뜻으로, 역할(Role)에 의존하게 해야 한다는 것과 같음. 구현체에 의존하게 되면 변경이 아주 어려워지며, 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있음.
그런데 OCP에서 설명한 MemberService는 인터페이스에 의존하지만, 구현 클래스도 동시에 의존함. (MemberService 클라이언트가 구현 클래스를 직접 선택 - DIP 위반)
❗️ SRP 와 ISP 는 객체가 커지는 것을 막아줌.
객체가 단일 책임을 갖도록 하고 클라이언트마다 특화된 인터페이스를 구현하게 함으로써 한 기능의 변경이 다른 곳까지 미치는 영향을 최소화하고, 이는 기능 추가 및 변경에 용이하도록 만들어 줌.
❗️ LSP 와 DIP 는 OCP 를 서포트함. OCP 는 자주 변화되는 부분을 추상화하고 다형성을 이용함으로써 기능 확장에는 용이하되 기존 코드의 변화에는 보수적이도록 만들어 줌. 여기서 '변화되는 부분을 추상화'할 수 있도록 도와주는 원칙이 DIP 이고, 다형성 구현을 도와주는 원칙이 LSP.
❗️ DIP와 OCP의 차이점
DIP는 시스템의 클래스와 모듈 간의 종속성과 관련이 있음. 즉, DIP는 모듈 간의 느슨한 결합을 촉진하여 시스템을 보다 유연하고 유지 관리하기 쉽게 만들음. 반면에 OCP는 시스템의 개방성과 확장성에 관심이 있음. 소프트웨어 엔터티의 동작은 소스 코드를 수정하지 않고도 쉽게 확장할 수 있어야 하며, 이는 기존 코드의 재사용을 촉진하고 오류 발생 위험을 줄이며 시스템을 보다 유연하게 만듦.
⏺️ 상수(constant)
상수는 변하지 않는 변수를 뜻하며, 상수에 넣는 데이터로는 숫자만 오는 것이 아니라 클래스나 구조체 같은 객체도 올 수 있음. 참조변수를 상수로 지정할 때, 참조변수 안의 속성의 데이터까지도 변하지 않는다고 생각할 수 있지만, 참조변수 메모리의 주소값이 변하지 않는다는 의미일 뿐, 그 주소가 가리키는 데이터들은 변할 수 있음.
const a = { name: "JY", age: 20 };
a = [ apple, banana ]; // 불가능
a.age = 10; // 가능
⏺️ 리터럴(Literal)
리터럴은 데이터(값) 그 자체를 뜻함. 즉, 변수에 넣는 변하지 않는 데이터를 의미하는 것.
const a = 1; //여기서 a는 상수이고, 1은 리터럴
⏺️ 리터럴 표기법
코드 상에서 데이터를 표현하는 방식을 리터럴이라 하고, 객체지향언어에서는 객체의 리터럴 표기법을 지원함. 리터럴표기법이란, 변수를 선언함과 동시에 그 값을 지정해주는 표기법을 말함.
//리터럴 표기법
var no = 3;
var obj = { name: 'JY', age: 20 }; // 객체리터럴 방식으로 만든 객체
this는 인스턴스 자신을 가르키는 참조 변수이고, this()는 생성자를 뜻함.
⏺️ this
class Car {
String color; // 인스턴스 변수
String gearType;
int door;
Car(String color, String gearType, int door){
this.color = color;
this.gearType = gearType;
this.door = door;
}
}
this 는 위 코드처럼 생성자의 매개변수로 선언된 변수의 이름이 인스턴스 변수와 같을 때 인스턴스 변수와 지역변수를 구분하기 위해서 사용함. Car() 생성자 안에서의 this.color는 인스턴스 변수이고, color는 매개변수로 정의된 지역변수임.
❗️static 메서드에서는 this를 사용하지 못함.
⏺️ this()
class Car{
String color; // 인스턴스 변수
String gearType;
int door;
Car(){
this("white", "auto", 4); // Car(String color, string gearType, int door)를 호출
}
Car(String color){
this(color, "auto", 4);
}
Car(String color, String gearType, int door){
this.color = color;
this.gearType = gearType;
this.door = door;
}
}
this()는 같은 클래스의 다른 생성자를 호출할 때 사용. 위 코드의 Car() 생성자와 Car(String color) 생성자는 this()를 통해 모두 Car(String color, String gearType, int door) 생성자를 호출하고 있음.
⏺️ final
"최종적인"이라는 의미로, 해당 변수에 값이 저장되면 수정이 불가능하다는 의미.
final 필드에 값을 저장하는 방법은 선언과 동시에 값을 주는 방법과, 생성자에 의해 값을 주는 방법이 있음.
public class Shop{
final int closeTime = 21;
final int openTime;
public Shop(int openTime){
this.openTime = openTime;
}
}
⏺️ static
"고정된"이라는 의미로, 객체 생성 없이 사용할 수 있는 필드와 메소드를 생성하고자 할 때 활용함.
공용 데이터에 해당하거나 인스턴스 필드를 포함하지 않는 메소드를 선언하고자 할 때 사용하며, 사용하기 위해선 클래스 내에서 필드나 메소드 선언 시 static 키워드를 붙여주기만 하면 됨.
// 선언
public class PlusClass{
static int field1 = 15;
static int plusMethod(int x, int y){ return x+y; }
}
// 사용
int ans1 = PlusClass.plusMethod(15,2);
int ans2 = PlusClass.field1 + 2;
⏺️ static final
static+final = "고정된+최종적인"의 의미로, 상수를 선언하고자 할 때 사용됨.
상수란 fixed로 변하지 않는 값을 뜻하는데, final만으로는 상수가 되기에 충분하지 않음. 위의 예시에서 알 수 있듯이, closeTime은 21로 변하지 않지만, openTime은 객체마다 다를 수 있음을 보였으므로 final 자체만으로는 상수를 의미할 수 없음.
static final double PI = 3.141592;
해당 값은 객체마다 저장될 필요가 없으며(static의 성질) + 여러 값을 가질 수 없음(final의 특징).
⏺️ super
자신이 상속받은 부모를 가리키는 참조 변수.
자식 클래스는 부모클래스를 상속받았기 때문에 자유롭게 부모의 모든 프로퍼티를 사용할 수 있음. 자식클래스가 부모클래스의 프로퍼티와 동일한 이름을 갖고 있다면 그것을 부모로부터 구분해 낼 수 있어야하는데, super라는 참조 변수를 활용하면 이것이 가능함.
class Object{
int a;
}
class A extends Object{
int a;
}
public static void main(String args[]){
A ins = new A();
ins.a=2 // 여기서 a는 A의 a, 즉 자식의 변수
// 만약 자식에게 a라는 변수가 없었다면 부모의 a를 가리켰을 것임
// 여기서 자식 a가 아닌 부모의 a로 접근하고 싶다면?
ins.super.a = 20; // 이렇게 super라는 참조 변수를 사용해서 부모의 a에 접근할 수 있음
}
// 위와 같은 이유로 자바에서는 다중 상속
// (어떤 클래스가 두개 이상의 상위 클래스로부터 여러 가지 행동이나 특징을 상속받는 것)
// 이 불가능함 (상속의 모호성)
⏺️ super()
자신이 상속받은 부모의 생성자를 호출하는 메소드.
자식클래스가 인스턴스를 생성하면, 인스턴스 안에는 자식 클래스의 고유 멤버 뿐만 아니라 부모 클래스의 모든 멤버까지 포함되어있음. 하지만 생성자는 상속되지 않는 유일한 멤버함수이기 때문에, 부모클래스의 멤버를 초기화하기 위해선 부모클래스의 생성자를 호출해야함. 즉, 자식클래스 생성자를 호출할 때 부모클래스 생성자도 동시에 호출해야함. (정확히 말하면 부모 생성자가 먼저 실행됨)
❗️일반적으로 자바 컴파일러가 자동으로 super() 메소드를 추가해주지만, 부모클래스에 기본 생성자가 아닌 매개변수를 가지는 생성자가 있다면(=부모클래스에서 생성자가 오버로딩되면) 자동으로 추가되지 않음 (=묵시적 제공을 하지 않음)
👉 해결 방법: 자식클래스 생성자 호출할 때 super()을 활용 + ⭕️ 1, 2번 중 선택 (2번이 조금 더 합리적)
class Parent{
int a;
Parent(){a=10;}; // ⭕️ 1.부모클래스에 기본 생성자를 선언해주거나,
Parent(int n){a=n;};
}
class Child extends Parent(){
int b;
Child(){
super(); //
super(40); // ⭕️ 2. 오버로딩된 생성자에 맞춰 super()의 인자를 맞춰줘야함 (반드시!!)
b=20;
}
}
⏺️ 원시 타입 (Primitive Type)
원시 타입은 정수, 실수, 문자, 논리 리터럴등의 실제 데이터 값을 저장하는 타입.
종류 | 데이터형 | 크기 (byte/bit) | 포현 범위 |
---|---|---|---|
논리형 | boolean | 1 / 8 | true 또는 false |
문자형 | char | 2 / 16 | '\u0000' ~ '\uFFFF' (16비트 유니코드 문자 데이터) |
정수형 | byte | 1 / 8 | -128 ~ 127 |
정수형 | short | 2 / 16 | -32768 ~ 32767 |
정수형 | int | 4 / 32 | -2147483648 ~ 2147483647( -21억 ~ + 21억) |
정수형 | long | 8 / 64 | -9223372036854775808 ~ 9223372036854775807(-100경 ~ + 100경) |
실수형 | float | 4 / 32 | 1.4E-45 ~ 3.4028235E38 |
실수형 | double | 8 / 64 | 4.9E-324 ~ 1.7976931348623157E308 |
❗️ int형 데이터 타입의 범위를 넘어서는 long 데이터 타입의 정수를 사용하고자 하는 경우에는 정수 데이터 맨 뒤 쪽에, 접미사 'l' 이나 'L'을 붙여줘야함.
❗️ 기본형 데이터타입이 double형 이기 때문에 float형 데이터타입의 실수형 데이터를 사용하고자 하는 경우 long형과 마찬가지로 실수 데이터 맨 뒤 쪽에 접미사 'f'나 'F'를 붙여줘야함.
⏺️ 참조 타입 (Reference Type)
원시 타입을 제외한 타입들(문자열, 배열, 열거, 클래스, 인터페이스)을 말함.
Java에서 실제 객체는 힙 영역에 저장되며, 참조 타입 변수는 스택 영역에 실제 객체들의 주소를 저장하여 객체를 사용할때 마다 참조 변수에 저장된 객체의 주소를 불러와 사용하는 방식을 사용. 힙 영역에 저장되는 객체들은 기본타입 변수들과는 다르게 크기가 정해져 있지 않으며, 프로그램 실행시 메모리에 동적으로 할당됨. 참조하는 변수가 없으면 자바의 가비지 컬렉터가 제거함.
⏺️ 원시 타입과 참조 타입의 차이
❗️ Null 포함 가능 여부: 원시타입은 null을 담을 수 없지만, 참조 타입은 가능함.
// 불가능
int i = null;
// 가능
Integer integer = null;
❗️ 제너릭 타입에서 사용 가능 여부
원시타입은 제너릭 타입에서 사용할 수 없지만, 참조 타입은 가능하다.
// 불가능
List<int> list;
// 가능
List<Integer> list;
1️⃣ POJO 프로그래밍 지향 (Plane Old Java Object)
❗️ 순수 Java로만 이루어진 객체를 짜는 것을 POJO 프로그래밍이라고 함.(외부의 영향을 받지 않기 위해)
Java 및 Java의 스펙에 정의된 기술만 사용한다는 의미로, 어떤 객체가 외부의 라이브러리나 외부의 모듈을 가져와서 사용하고 있다면 그 객체는 POJO라고 할 수 없음. 순수 Java 외에 외부 기술을 사용하고 있을 경우, 개선된 신 기술이 등장하여 기존 기술과 관련된 코드를 모두 고쳐야하는 상황이 발생하면 해당 기술을 사용하고 있는 모든 객체들의 코드를 전부 바꿔주어야만 함. 😨
POJO를 이용하면 외부 기술이나 규약의 변화에 얽매이지 않아 보다 유연하게 변화와 확장에 대처할 수 있으며, 객체지향 설계를 제한없이 적용할 수 있으며, 코드가 단순해져 테스트와 디버깅 또한 쉬워짐.
2️⃣ IoC / DI (Inversion of Control & Dependency Injection)
A 인스턴스가 B 인스턴스의 메서드를 호출하고 있다면 A는 B와 의존 관계를 맺은 것이 되며, 이 둘의 관계를 “A가 B에 의존하는 관계”라고 표현할 수 있음. 만약, A가 사용할 객체를 B가 아니라, 새롭게 C를 정의해서 사용하고자 한다면 A의 코드를 변경해야함. 만약 기존에 B를 사용하던 객체가 A 뿐만 아니라, 수십 또는 수백개가 있다면 모든 객체의 코드를 수정해주어야 함. 😨
❗️ 이를 방지하기 위해 Spring에서는 A가 자신이 사용할 객체를 스스로 생성하지 않고, 생성자를 통해 외부로부터 받아옴. Spring을 사용하면 애플리케이션의 로직 외부에서 A가 사용할 객체를 별도로 설정할 수 있음. 개발자가 설정 클래스 파일에 A가 사용할 객체를 C로 설정해두면, 애플리케이션이 동작하면서 Spring이 설정 클래스 파일을 해석하고, 개발자가 설정해둔대로 C 객체를 생성하여 A의 생성자의 인자로 C를 전달해줌.
❗️ 개발자가 아닌 Spring이 A가 사용할 객체를 생성하여 의존 관계를 맺어주는 것을 IoC(Inversion of Control, 제어의 역전)라고 하며, 그 과정에서 C를 A의 생성자를 통해 주입해주는 것을 DI(Dependency Injection, 의존성 주입)라고 함.
3️⃣ AOP (Aspect Oriented Programming)
애플리케이션을 개발할 때에 구현해야 하는 기능들은 크게 공통 관심 사항과 핵심 관심 사항으로 분류할 수 있음.
핵심 관심 사항은 애플리케이션의 핵심 기능과 관련된 관심 사항으로, 커피 주문 애플리케이션을 예로 든다면 메뉴 등록하기, 주문하기, 주문 변경하기 등이 있을 것임.
공통 관심 사항은 모든 핵심 관심 사항에 공통적으로 적용되는 관심 사항들을 의미합니다. 예를 들어 모든 핵심 관심 사항에는 로깅이나 보안 등과 관련된 기능들이 공통적으로 적용되어야만 함.
이 때, 핵심 관심 사항과 공통 관심 사항이 코드에 모여 있으면, 공통 관심 사항과 관련된 코드가 중복될 수밖에 없음. 코드가 중복되어져 있는 경우, 공통 관심 사항을 수행하는 로직이 변경되면 모든 중복 코드를 찾아서 일일이 수정해주어야만 함. 😨
이를 해결하기 위해서는 공통 관심 사항과 관련된 기능들을 별도의 객체로 분리해낸 다음, 분리해낸 객체의 메서드를 통해 공통 관심 사항을 구현한 코드를 실행시킬 수 있도록 해야 함. ❗️ 이처럼, 애플리케이션 전반에 걸쳐 적용되는 공통 기능을 비즈니스 로직으로부터 분리해내는 것을 AOP(Aspect Oriented Programming, 관심 지향 프로그래밍)라고 함.
4️⃣ PSA (Portable Service Abstraction)
❗️ 특정 기술과 관련된 서비스를 추상화하여 일관된 방식으로 사용될 수 있도록 한 것을 PSA(Portable Service Abstraction, 일관된 서비스 추상화)라고 함.
만약 MySQL을 사용하여 개발을 완료했는데, Maria DB로 데이터베이스를 바꿔야 한다면, 기존에 작성한 코드를 전부 지우고 새로 작성해야 하거나, 기존 데이터베이스와 새로운 데이터베이스 간에 사용 방법이 다른 코드를 모두 찾아서 일일이 수정해주어야 할 것임.
그러나, Spring을 사용하면 동일한 사용방법을 유지한 채로 데이터베이스를 바꿀 수 있음. 이는 Spring이 데이터베이스 서비스를 추상화한 인터페이스를 제공해주기 때문에 가능함. 즉, Spring은 Java를 사용하여 데이터베이스에 접근하는 방법을 규정한 인터페이스를 제공하고 있으며, 이를 JDBC라고 함.
각 데이터베이스를 만든 회사들은 자신의 데이터베이스에 접근하는 드라이버를 Java 코드의 형태로 배포하는데, 이 드라이버에 해당하는 Java 코드의 클래스가 JDBC를 구현함. 따라서, JDBC를 기반으로 하여 데이터베이스 접근 코드를 작성해두면, 이후에 데이터베이스를 바꾸어도 기존에 작성한 데이터베이스 접근 로직을 그대로 사용할 수 있음.
0️⃣ HTTP 요청: client가 HTTP 요청을 보내면, Dispatcher Servlet이 가장 먼저 요청을 받음.
1️⃣ 핸들러(컨트롤러) 조회: HTTP 요청에 맞는 핸들러(컨트롤러)가 무엇인지 조회함.
2️⃣ 핸들러 어댑터 조회: 1번 과정에서 조회한 핸들러를 처리할 수 있는 핸들러 어댑터를 조회함.
3️⃣ 핸들러 어댑터 실행: handle() 메소드를 동해 핸들러 어댑터를 실행함.
4️⃣ 핸들러(컨트롤러) 실행: 핸들러 어댑터가 핸들러(컨트롤러)를 실행함. 실제 비즈니스로직이 작동하게 됨.
5️⃣ ModelAndView 반환: 핸들러 어댑터가 핸들러의 반환 정보를 ModelAndView 형식에 맞추어 변환한 뒤, Dispatcher Servlet으로 반환.
6️⃣ viewResolver 호출: 5번 과정에서 받은 ModelAndView를 통해 viewResolver에게 view의 이름을 전달.
7️⃣ View 반환: viewResolver가 view의 논리 이름을 물리 이름으로 바꾸고, 렌더링을 수행하는 view 객체를 반환함.
8️⃣ View 렌더링: view 객체에게 ModelAndView의 model에 있는 정보를 넘겨주면서 render() 메소드를 통해 페이지가 렌더링되도록 함.
9️⃣ HTTP 응답: 렌더링된 페이지를 client에게로 응답.