테스트 자동화 인프라 구축 프로젝트를 여러번 진행해 오면서 알게된 내용과 필요한 내용을 정리하고자 한다.
BDD(Behavior Driven Development)
행위 주도 개발이라는 뜻의 개발방법론인 BDD.
소프트웨어 개발 과정을 개선하기 위해 사용되는 방법론이다.
Agile 개발 방법론의 일종으로, 소프트웨어 프로젝트의 개발을 가이드하기 위해 행동 기반의 언어를 사용한다.
행동 기반의 언어라는 건 이런 것이다.
1. B 화면이 보인다. 2. B화면의 우측 상단에 [A] 버튼을 클릭한다. 3. 클릭한 버튼 하단 셀렉트 박스에 [...] 텍스트가 확인된다.
이 방법론의 핵심은 개발자, 테스터, 비즈니스 분석가 등 프로젝트에 참여하는 모든 사람이 소프트웨어의 동작을 명확하고 이해하기 쉬운 방식으로 정의하고, 이러한 동작을 기반으로 커뮤니케이션하며, 결국에는 테스트와 개발을 진행하는 것이다.
BDD는 기능적 요구사항을 사람이 읽을 수 있는 언어로 작성된 시나리오로 변환하여 기술적인 사양과 비즈니스 요구 사이의 이해를 돕는다.
Gherkin
BDD의 구현을 위해 사용되는 도메인 특화 언어(Domain Specific Language, DSL)이다.
사람이 읽을 수 있는 말로 작성되며, 특정 기능이 어떻게 동작해야 하는지를 시나리오 형식으로 기술한다.
Gherkin으로 작성된 시나리오는 주로 Given - When - Then 형식을 따른다.
Given: 테스트의 전제 조건
When: 수행할 액션
Then: 예상 결과
이런 방식으로 Gherkin은 비즈니스 로직을 명확하게 기술하고 이를 바탕으로 테스트 케이스를 만들어 내는데 이상적인 문법이다.
Cucumber
Gherkin으로 작성된 시나리오도 결국 테스트 시나리오이니까 실행할 수 있는 도구가 필요하다.
그 도구가 Cucumber라는 소프트웨어 툴이다.
이는, BDD 프로세스를 지원하기 위해 설계되었으며, Gherkin 시나리오를 자동화 된 테스트로 변환한다.
Cucumber는 다양한 프로그래밍 언어를 지원하고 개발자가 Gherkin 시나리오를 바탕으로 테스트 코드를 작성할 수 있도록 해준다.
결과적으로 Cucumber를 사용함으로써 팀은 비즈니스 요구사항이 정확히 이해되고 충족되는지를 보다 쉽게 검증할 수 있다.
BDD, Gherkin, Cucumber와 자동화
이 방법론과 도구는 함께 작동할 때 가장 효과적이다. BDD는 프로세스와 커뮤니케이션의 틀을 제공하며, Gherkin은 이 틀 내에서 비즈니스 요구사항을 명확하고 일관된 형식으로 기술하는 방법을 언어적으로 풀어 제공한다. 마지막으로 Cucumber는 Gherkin으로 작성된 시나리오를 실행 가능한 테스트로 전환하여, 요구사항이 제대로 충족되었는지를 자동으로 검증할 수 있게 해준다.
이러한 조합은 비즈니스 요구사항을 정확하게 이해하고, 이를 기반으로 고품질의 소프트웨어를 더 빠르게 개발하는 데 도움을 준다.
또한 사람이 읽을 수 있는 언어로 애플리케이션의 기능을 설명하듯 테스트 시나리오를 만들기 때문에 애플리케이션의 특정 기능에 대한 문서화가 대체될 수 있다는 점에서 장점을 가진다고 볼 수 있다.
그래서 결국 더 예측 가능하고 관리 가능하며 비즈니스 요구사항과 기술적 구현 사이의 간극을 줄이는 데 초점을 맞추고 있다.
Hash Table은 Key/Value Pair를 빠르게 저장하고 읽을 수 있는 자료구조이다.
파이썬에서 Dictionary라고 부르는 것이 이 Hash Table이다.
예를 들어, Key: Food, Value: Kimchi를 Hash Table에 저장하고자 하면 Hash Table한테 Key가 Food고 Value가 Kimchi인 이 Pair를 저장해 줘! 하고 저장을 한 다음 이후에 Food라는 Key의 Value가 어떻게 돼?라고 물어보면 Hash Table이 Kimchi입니다라고 말해주는 자료구조.
Hash Table의 구현 원리
이름 그대로 Table(배열)과 Hash Function으로 구성되어 있다.
Hash Function은 임의의 길이를 갖는 임의의 데이터를 고정된 길이의 데이터로 매핑하는 단방향 함수를 말한다.
쉽게 말하면 어떤 값이던간에 이 Hash Function에 넣으면 고정된 값(예를 들어 숫자)으로 만들어준다는 것을 말한다.
다음 그림을 보자.
어떤 값을 Input으로 만들던간에 Hash Function에 넣으면 위 조건처럼 10보다 작은 숫자 뱉어낸다.
중요한 건, Input이 같은 값이라면 Output도 무조건 같은 값으로 나온다.
그래서 Hash Table의 구성은 다음과 같다.
Size가 N인 배열(Table)
Output이 N보다 작은 Hash Function
그래서 그림으로 보면 다음과 같다.
Hash Function, Table이 있는 Hash Table.
그래서 누군가가 Key "Food", Value "Kimchi"라는 Pair를 이 Hash Table에 저장하겠다고 한다.
1. Hash Table은 저 Key를 Hash Function에 집어넣는다. 그리고 나온 Output은 4.
2. 4라는 숫자를 가진 인덱스에 Value를 저장한다.
이게 아주 간단한 Hash Table인데 문제가 있다. 충돌의 문제.
예를 들어, 10개 이상의 값을 이 Hash Table에 넣으면 결국 Hash Function은 0 -10 사이의 숫자만을 뱉어내는 녀석이기 때문에 그 값들 중엔 중복되는 값이 생길 수밖에 없다.
간단한 예로, 위 그림처럼 인덱스 4에 Kimchi가 저장된 상태인데 누군가가 Key "FavoriteFood" Value "Pizza"라는 Pair를 이 Hash Table에 저장하려고 시도하는데 Hash Function이 같은 4를 뱉어냈다고 생각해 보면 아래 그림처럼 된다.
그럼 아까 Food/Kimchi를 저장했던 사람이 Food에 담긴 Value뭐야? 라고 물어보면 이 Hash Table은 Pizza라는 엉뚱한 값을 뱉을 수 있게 된다. 이걸 어떻게 해결할까?
Hash Table의 Collision 해결 방법
크게 두가지 방법이 있다.
Chaining
Probing
Chaining
우선 첫번째 방법인 Chaining은 그냥 배열을 쓰는 게 아니라 Linked List를 사용해서 Collision이 발생할 때마다 해당 인덱스(이 예시에선 4)에 있는 Linked List에 값을 연달아서 저장하는 것. 아래 그림을 보자.
위 문제를 이런식으로 해결했다. 해당 인덱스에 Linked List로 차곡차곡 똑같이 넣는 것. 대신 이제는 Value만 넣을 순 없다. 이 Linked List에 여러 값이 들어가니까 어떤 Key에 해당하는지 알기 위해 Key/Value를 같이 넣어줘야 한다.
그래서 이제 유저가 "Key가 Food인 Value 찾아줘!" 하고 Hash Table에 요청을 하면 Hash Table은 먼저 Hash Function에 Input으로 Food를 넣어서 나온 값 4를 가진 메모리에 가면 나오는 Linked List에서 Iterator를 돌려서 원하는 값(Kimchi)을 찾아내는 것.
참고: Linked List 말고Binary Search Tree를 사용해도 된다. 이전 포스팅에서 정리한 Binary Search Tree. 이 녀석을 사용하면 원하는 값을 찾아내는 시간 복잡도는 O(log N)이기 때문에 더 효율적일 수도 있다. 상황에 따라.
Probing
Probing도 여러 가지 방법이 있는데 그중 가장 간단한 Linear Probing을 보면 배열을 그대로 쓰고 Hash Function이 같은 값을 뽑아내면 우선 그 자리에 가보는데 값이 있을 때 그 옆을 보는 방식이다. 아래 그림을 보자.
1. Food/Kimchi라는 Pair를 Hash Table에 넣었더니 4번에 들어간 상태이다.
2. Favorite Food/Pizza라는 Pair를 Hash Table에 넣으려고 시도하는데 같은 4번이 나왔고 4번 자리를 보니 이미 값이 있어서 Hash Table이 그 옆자리에 넣었다.
이게 Linear Probing이다. 그래서 Linear Probing을 정리하자면,
Linear Probing : 이미 자리에 값이 있으면 다음 자리에 추가한다.
근데, 이 문제의 단점이 있다. Collision이 많이 일어나면 그 주변에 병목현상이 생길 수 있다는 것.
예를 들어, 저 모양 그대로 HateFood/Coffee라는 Pair를 넣으려고 시도하는데 Hash Function이 또 4를 뱉어냈다. 그래서 4에 가보니 값이 있어서 그다음 5에 가보니 또 값이 있어서 6으로 가서야 넣게 됐다.
그래서 이 Linear Probing 문제를 또 해결하는 방법이 Quadratic Probing이다.
Quadratic Probing
다음 자리를 찾을 때 hash(x) + i가 아니라 hash(x) + i^2 자리에 넣는 방식이다. 그러니까 4가 나오면 Linear Probing은 5에 넣는 건데 Quadratic Probing은 16에 넣는다.
Hash Table의 시간 복잡도와 공간 복잡도
시간 복잡도
두 가지 케이스로 나뉠 수 있다.
Hash Collision이 없을 때
데이터 추가
Key 값 해시하기 O(1)
데이터 넣기 O(1)
O(1) + O(1) = O(1)
데이터 읽기
Key 값 해시하기 O(1)
데이터 읽기 O(1)
O(1) + O(1) =O(1)
Hash Collision이 있을 때
데이터 추가/읽기
Key 값 해시하기 O(1)
최악의 경우 데이터 넣기, 읽기가 O(N)이 될 수 있음.
(왜냐하면, Hash Function이 잘못 만들어진 경우 계속해서 같은 Output을 만들어 내는 최악의 Hash Function이 있으면 Linked List 추가의 경우 마지막 자리를 알기 위해 1-N까지 데이터가 있을 때 처음부터 하나씩 자리를 찾아야 하고, 읽기의 경우 해당 데이터가 어디 있는지 알기 위해 N개의 노드를 다 뒤져야 할 수도 있으므로)
근데, 이건 정말 최악의 경우인 거고 어지간하면 O(1)이 된다. 우선 Key 값 해시하는 건 언제나 O(1)이고 Hash Function이 문제없이 잘 만들어졌다면 바로바로 데이터를 넣거나 읽을 수 있게 된다.
그래서 어지간하면 이 경우도 O(1)이 맞고, 정말 정말 잘못 만들어진 Hash Function이라면 O(N)이 될 수도 있다는 것으로 알아두면 좋을듯하다.
공간 복잡도
구분 없이 O(N).
N개의 데이터를 넣으려면 그에 맞는 배열 사이즈가 있어야 하고(N) 해당 데이터 타입의 Byte를 가지는 메모리 공간(X) = N * X => O(N).
이런 자료구조를 Stack이라고 한다. 그럼 자주 들어봤던 Stack Overflow는 어떤 순간에 발생하는걸까?
일단 Overflow니까 꽉 차서 더 못넣는다는 얘기인데, 자바 메모리 구조를 생각해보면 항상 실행하는 메서드를 순차적으로 Stack에 집어넣게 되어 있다.
최초의 main() 메서드가 실행되고 main()에서 A()라는 메서드를 호출하고 A()메서드가 B()라는 메서드를 호출하면 처리하는 순서는 B -> A -> main이 된다. 이게 정확하게 Stack의 구조다.
그래서 이 Stack에 계속해서 쌓이는 무언가가 Stack Overflow를 일으키는데 대표적인 예시가 자기 자신을 무한하게 호출하는 경우가 그렇다. 자기가 자기 자신을 계속 호출 호출 호출 무한루프에 빠지는거지. 그럴때 이제 더 이상 Stack에 넣을 자리가 없어서 Stack Overflow가 발생한다.
클래스 안에 클래스를 중첩해서 정의할 수 있는데, 이것을 중첩 클래스(Nested Class)라고 한다.
중첩 클래스 예시
class Outer {
...
class Nested {
...
}
}
근데 중첩 클래스는 클래스를 정의하는 위치에 따라 다음과 같이 분류한다.
이 네가지가 모두 중첩 클래스라고 불리는데, 여기서 2가지로 분류가 가능하다.
정적 중첩 클래스 (static)
내부 클래스 종류
내부 클래스
지역 클래스
익명 클래스
중첩 클래스는 변수를 선언하는 위치와 같다.
변수의 선언 위치
클래스 변수 (static)
인스턴스 변수
지역 변수
중첩 클래스 선언 위치
정적 중첩 클래스 -> 정적 변수(클래스 변수)와 같은 위치
내부 클래스 -> 인스턴스 변수와 같은 위치
지역 클래스, 익명 클래스 -> 지역 변수와 같은 위치
class Outer {
...
// 정적 중첩 클래스
static class StaticNested {
...
}
// 내부 클래스
class Inner {
...
}
public void method() {
...
// 지역 클래스
class Local() {...}
Local local = new Local();
...
}
}
익명 내부 클래스는 저렇게 클래스를 선언하고 사용하는 게 아니라, 추상 클래스나 인터페이스 같은것을 상속받은 클래스가 필요한데 이걸 굳이 클래스 파일로 만들어서 클래스라는 키워드를 사용해서 만드는게 아니라 한 번 사용하고 말거 같을 때 코드 내에서 선언할 수가 있다.
그러니까 엄밀히 말하면 정적 중첩 클래스와 내부 클래스는 완전히 다른 거고 내부 클래스를 정적 중첩 클래스라고 말하거나 정적 중첩 클래스를 내부 클래스라고 말하면 안된다. 근데 말하면서 그냥 섞어 쓰니까 상황과 문맥에 따라 잘 이해해서 받아들이면 된다.
중첩 클래스는 언제 사용할까?
내부 클래스를 포함한 모든 중첩 클래스는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나, 둘이 아주 긴밀하게 연결되어 있는 경우에만 사용해야 한다. 외부의 여러 클래스가 특정 중첩 클래스를 사용한다면 중첩 클래스로 만들면 안된다.
중첩 클래스를 사용하는 이유
논리적 그룹화: 특정 클래스가 다른 하나의 클래스 안에서만 사용되는 경우 해당 클래스 안에 포함하는 것이 논리적으로 더 그룹화된다. 패키지를 열었을 때 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는 장점도 있다.
캡슐화: 중첩 클래스는 바깥 클래스의 private 멤버에 접근할 수 있다. 이렇게 해서 둘을 긴밀하게 연결하고 불 필요한 public 메서드를 제거할 수 있다.
정적 중첩 클래스
정적 중첩 클래스의 사용 예시를 보자.
public class StaticNestedClass {
private static int outerStaticValue = 1;
private int outerInstanceValue = 2;
static class InStaticNestedClass {
private int inStaticNestedInstanceValue = 10;
public void print() {
// 본인의 인스턴스 변수에 당연히 접근 가능
System.out.println("inStaticNestedInstanceValue = " + inStaticNestedInstanceValue);
// 외부 클래스의 클래스 변수에 접근이 가능 (근데, private 이어도 가능한게 유일한 차이)
System.out.println("outerStaticValue = " + outerStaticValue);
// 외부 클래스의 인스턴스 변수에는 접근이 불가능, 왜냐하면 static을 생각해보면 됨 static이니까 같은 static만 접근이 가능하다고 보면 됨
// 인스턴스 영역(힙 영역)에 저 변수에 접근할 수 있는 방법이 없음
// System.out.println("outerStaticValue = " + outerInstanceValue);
}
}
}
정적 중첩 클래스는 외부의 클래스와 그냥 다른 클래스라고 보면 된다.
그러니까 다음 코드랑 동일하다고 보면 된다.
class StaticNestedClass {
}
class InStaticNestedClass {
}
근데 한가지 다른 하나, 정적 중첩 클래스는 외부 클래스의 private 클래스 변수에 접근이 가능하다.
private에 접근이 가능한 이유는 내부에 있기 때문. private은 자기 자신에서는 접근이 가능한데 정적 중첩 클래스는 결국 {} 안에 존재하기 때문에.
호출은 이렇게 하면 된다.
Main
public class StaticNestedMain {
public static void main(String[] args) {
StaticNestedClass.InStaticNestedClass inStaticNestedClass = new StaticNestedClass.InStaticNestedClass();
inStaticNestedClass.print();
}
}
그럼 정적 중첩 클래스는 어느 순간에 써야하는지 예제를 통해 알아보자.
정적 중첩 클래스의 사용 예시
NetworkMessage
public class NetworkMessage {
private String content;
public NetworkMessage(String content) {
this.content = content;
}
public void print() {
System.out.println(content);
}
}
Network
public class Network {
public void sendMessage(String text) {
NetworkMessage networkMessage = new NetworkMessage(text);
networkMessage.print();
}
}
이렇게 두 개의 클래스가 있다. 이때 NetworkMessage 클래스는 외부 어디에서도 사용하지 않고 Network 클래스에서만 사용하고 있다고 가정해보자.
이걸 만든 사람과 이걸 가져다가 사용하는 사람이 서로 다른 사람이라고 가정할 때 가져다가 사용하는 사람은 이렇게 두 개의 클래스를 보면 이런 생각이 든다. 어? 뭘 사용해야하지? 가 첫번째. 두번째는 어떻게 잘 파악해서 "아 Network 클래스에서 NetworkMessage를 가져다가 사용하니까 Network 클래스를 사용하면 되겠구나." 라고 생각을 어찌저찌 하긴 할 것이다.
그런데 이렇게 짠 코드가 아니라 다음 코드를 보자.
Refactoring된 Network
public class Network {
public void sendMessage(String text) {
NetworkMessage networkMessage = new NetworkMessage(text);
networkMessage.print();
}
private static class NetworkMessage {
private String content;
public NetworkMessage(String content) {
this.content = content;
}
public void print() {
System.out.println(content);
}
}
}
이렇게 정적 중첩 클래스로 안에서 만들고 심지어 이 클래스의 접근제어자가 private이면 외부에서는 저 클래스에 아예 접근 자체를 하지 못하게 하겠다는 강한 의지이기 때문에 이걸 가져다가 사용하는 개발자는 아무런 고민할 필요가 없다. 애시당초에 클래스 파일자체가 이것 하나만 있으니 다른 클래스를 더 볼 필요도 없고 이렇게 Network에서만 사용되는 클래스라면 내부에 정적 중첩 클래스로 선언해서 사용하면 파일 개수도 줄고 가시성도 좋아진다.
정적 중첩 클래스는 이런 장점과 사용성이 있다. 내부 클래스 말고 정적 중첩 클래스(static)로 선언한 이유는 외부 클래스의 인스턴스 변수에 접근할 필요가 없으면 이게 더 확실한 의도를 보여줄 수 있기 때문.
내부 클래스
정적 중첩 클래스는 외부 클래스와 아무런 관련이 없다.
서로 다른 클래스다. 근데 한가지! 외부 클래스의 private 클래스 변수에도 접근이 가능하다는 점.
원래 클래스 변수면 어디서나 접근이 가능한데, private이면 그 클래스 내부에서만 접근이 가능한데 정적 중첩 클래스도 그 클래스 내부 {}에 존재하기 때문에 가능한 원리.
근데 이제 내부 클래스는 외부 클래스와 밀접한 관련이 있다. 그냥 외부 클래스의 인스턴스를 이루는 요소가 된다.
정적 중첩 클래스
static이 붙는다.
바깥 클래스의 인스턴스에 소속되지 않는다.
내부 클래스
static이 붙지 않는다.
바깥 클래스의 인스턴스에 소속된다.
예제 코드를 통해 내부 클래스를 알아보자.
public class InnerOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
class Inner {
private int innerInstanceValue = 1;
public void print() {
// 자기 자신에 접근
System.out.println("innerInstanceValue = " + innerInstanceValue);
//외부 클래스의 인스턴스 변수
System.out.println("outInstanceValue = " + outInstanceValue);
//외부 클래스의 클래스 변수
System.out.println("outClassValue = " + outClassValue);
}
}
}
내부 클래스는 앞에 static이 붙지 않고 인스턴스 멤버가 된다.
그래서 내부 클래스는 자신의 멤버에도 접근이 당연히 가능하고, 바깥 클래스의 인스턴스 멤버에도 접근이 가능하고, 바깥 클래스의 클래스 멤버에는 당연히 접근이 가능하다. 근데 여기서 private도 접근이 가능하다는 것만 좀 주의하면 된다.
그래서 이 내부 클래스는 어떻게 호출하고 사용할까?
public class InnerOuterMain {
public static void main(String[] args) {
InnerOuter outer = new InnerOuter();
InnerOuter.Inner inner = outer.new Inner();
inner.print();
}
}
내부 클래스는 다시 한번 말하지만 외부 클래스의 인스턴스에 속한다. 그래서 외부 클래스 없이는 내부 클래스 자체적으로 인스턴스를 생성할 수 없다. 그래서 아래처럼 외부 클래스의 인스턴스를 생성하고 그 외부 클래스 인스턴스의 내부 클래스를 만들어야 한다.
InnerOuter outer = new InnerOuter();
InnerOuter.Inner inner = outer.new Inner();
그래서 외부 클래스와 내부 클래스를 개념적으로 살펴보면 다음 그림처럼 생각하면 된다.
외부 클래스의 인스턴스가 내부 클래스의 인스턴스를 포함하고 있고 이런 구조이기 때문에 내부 클래스의 인스턴스는 바깥 인스턴스를 알기 때문에 바깥 인스턴스의 멤버에 접근이 가능하다.
이렇게만 알아둬도 충분한데 실제로 구조는 조금 다르다. 근데 저렇게 알아두면 된다.
실제 구조는 다음과 같다.
내부 인스턴스가 외부 인스턴스의 참조값을 가지고 있기 때문에 바깥 인스턴스의 멤버에 접근이 가능한 게 실제 구조다.
정리를 하자면
정적 중첩 클래스는 외부 클래스와 아무런 관련이 없다. 서로 다른 클래스이지만 외부 클래스가 관리하는 영역 {} 안에 있기 때문에 private으로 선언해도 접근이 가능할 뿐이다.
내부 클래스는 외부 클래스의 인스턴스에 속한다. 그렇기 때문에 내부 클래스는 생성할 때 외부 클래스의 인스턴스에 속한채로 생성되어야 한다. 그리고 내부 인스턴스는 외부 인스턴스를 알고 있기 때문에 외부 인스턴스 멤버에 접근이 가능하다.
내부 클래스의 용도
내부 클래스를 사용하지 않았을 때 코드를 먼저 보고 내부 클래스를 사용하면 어떻게 더 좋아지는지 보자.
내부 클래스를 사용하지 않았을 때
Car
package nested.inner.ex1;
public class Car {
private String model;
private int chargeLevel;
private Engine engine;
public Car(String model, int chargeLevel) {
this.model = model;
this.chargeLevel = chargeLevel;
this.engine = new Engine(this);
}
// Engine에서만 사용하는 메서드
public int getChargeLevel() {
return chargeLevel;
}
// Engine에서만 사용하는 메서드
public String getModel() {
return model;
}
public void start() {
engine.start();
System.out.println(model + " 시작 완료");
}
}
Engine
package nested.inner.ex1;
// Car Class 에서만 사용
public class Engine {
private Car car;
public Engine(Car car) {
this.car = car;
}
public void start() {
System.out.println("충전 레벨 확인: " + car.getChargeLevel());
System.out.println(car.getModel() + "의 엔진을 구동합니다.");
}
}
Main
package nested.inner.ex1;
public class CarMain {
public static void main(String[] args) {
Car car = new Car("Model Y", 100);
car.start();
}
}
지금 보면 어떤 문제가 있냐면 두가지 문제가 있는데,
1. Car 클래스에서만 사용하는 Engine 클래스를 두개의 클래스 파일로 나뉘어져 사용 중
2. Engine 클래스에서만 사용하는 Car 클래스에서 만든 메서드가 있음
이걸 어떻게 좋은 코드로 변경해볼까?
내부 클래스를 사용했을 때
Car
package nested.inner.ex2;
public class Car {
private final String model;
private final int chargeLevel;
private final Engine engine;
private class Engine {
public void start() {
System.out.println("충전 레벨 확인 = " + chargeLevel);
System.out.println(model + " 의 엔진을 구동합니다.");
}
}
public Car(String model, int chargeLevel) {
this.model = model;
this.chargeLevel = chargeLevel;
this.engine = new Engine();
}
public void start() {
engine.start();
System.out.println(model + " 시작 완료");
}
}
Car 클래스에서만 사용되는 Engine 클래스라면 그냥 Car 안에 private 내부 클래스로 선언하면 된다.
그리고 내부 클래스로 선언했기 때문에 Engine 클래스는 더이상 Car에 대한 인스턴스를 따로 받을 필요가 없다.
그리고 Engine 클래스는 내부 클래스이므로 외부 클래스의 인스턴스 멤버에 접근이 가능하기 때문에 다이렉트로 필드에 접근하면 된다. 그 말은 Car 클래스에서 불필요한 메서드를(불필요 하다기보단 오로지 Engine만을 위해 만든 메서드) 없앨 수 있다. 그리고 이 말은 캡슐화를 더 강력하게 해준다.
Main
package nested.inner.ex2;
public class CarMain {
public static void main(String[] args) {
Car car = new Car("Model Y", 100);
car.start();
}
}
슈퍼 간단해졌다. 이게 내부 클래스를 사용하는 이유이다.
이제 다시 처음으로 돌아가서 왜 중첩 클래스를 사용하냐?!
중첩 클래스(정적 중첩 클래스, 내부 클래스)는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나, 둘이 아주 긴밀하게 연결되어 있는 특별한 경우에만 사용해야 한다. 외부 여러곳에서 특정 클래스를 사용한다면 중첩 클래스로 사용하면 안된다.
사용하는 이유는 크게 두가지이다.
논리적 그룹화: 특정 클래스가 다른 하나의 클래스에서만 사용된다면 해당 클래스 안에 포함하는 게 더 논리적으로 그룹화가 된다. 패키지를 열었을 때 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는다는 장점도 있다.
캡슐화: 중첩 클래스는 바깥 클래스의 private 멤버에 접근이 가능하다. 그 말은 중첩클래스가 아니고 이 클래스만을 위해서 만든 메서드는 필요가 없다는 뜻이다. (위에 내부 클래스를 사용하지 않았을 때 Car 예시처럼 오로지 Engine 클래스만을 위해 public으로 메서드를 만들었다면 캡슐화가 약해지는 것이다.)
같은 이름의 바깥 변수 접근
아래 같은 경우를 말하는 것.
package nested.inner.ex2;
public class Shadowing {
private int value = 3;
private class InnerShadowing {
private int value = 5;
public void shadowing() {
int value = 10;
System.out.println("value = " + value); // 이 메서드의 지역 변수 value
System.out.println("this.value = " + this.value); // 내부 클래스의 인스턴스 변수 value
System.out.println("Shadowing.this = " + Shadowing.this.value); // 바깥 클래스의 value
}
}
public static void main(String[] args) {
Shadowing shadowing = new Shadowing();
InnerShadowing innerShadowing = shadowing.new InnerShadowing();
innerShadowing.shadowing();
}
}
이 쿼리를 날리면 컴퓨터는 age가 20인 행을 찾기위해 모든 행을 하나씩 다 뒤지게 된다. 뭐 지금처럼 레코드가 5개만 있으면 1초도 안 걸린다. 근데 1억개가 넘게 있으면 또는 그 이상 있으면 있을수록 느리게 동작할 것이다.
그러다가 어떤 특이점에 도달하게 되면 데이터베이스는 굉장히 무시무시한 일이 일어날 수 있다. 그렇기 때문에 이러한 특이점이 일어나기 전 컴퓨터에게 도움을 줄 수 있는데 그게 바로 index다.
index가 어떻게 동작하는지 쉽게 이해하기 위해 다음 그림을 보자.
1부터 100까지 숫자가 있는 카드가 있다고 치자. A와 B가 게임을 하는거다. A가 카드 한장을 뽑으면 B가 맞추는 형식이다.
이 때 B는 A한테 1이야? 2야? 3이야? 4야? ... 100이야? 이렇게 순차적으로 물어보는게 효율적일까? 물론 뽑은 카드가 1이라면 그럴 수 있겠지만 뽑은 카드가 100이라면 절대 아니다. B는 A한테 이런식으로 질문해야 더 적은 질문으로 더 빠른 해답을 찾을 수 있다.
"50보다 커?" - "75보다 작아?" 이렇게 중간지점에서 대소를 비교하는 식으로 말이다.
데이터베이스도 이런식으로 질문을 하면 더 효율적이지 않을까? 맞다. 그러나 여기서 전제조건이 있다.
순서대로 정렬이 되어 있어야 저렇게 반씩 날릴 수 있는 질문이 가능하다라는 것.
그래서, 데이터베이스에서도 저렇게 질문을 하고 싶다면 같은 컬럼을 복사해서 순서대로 정렬해 놓음이 필요하다.
그리고 이 정렬해서 복사해둔 컬럼을 index라고 부른다.
근데, 이 정렬하는 방식이 궁금하다. 정말 순서대로 저렇게 정렬해서 index를 만들까?
그렇다면 다음과 같이 그냥 Array나 Linked List로 순서대로 정렬해도 될 것 같다.
그런데 실제 데이터베이스들은 index를 만들 때 이렇게 만들지 않고 Tree 형태로 만든다. 뭐 이렇게 말이지.
즉, 모든 데이터들을 다 가져와서 일렬로 순서대로 정렬하는게 아니라, 아무렇게나 흩뿌려져 있는 데이터들을 가지고 와서 이렇게 가지치기 형식으로 정렬을 한다. 이렇게 해도 반으로 갈라낼수가 있기 때문이다. 예를 들어, 다음과 같은 질문을 받았다고 하자.
Q: 저는 5가 어디 저장되어 있는지 알고 싶어요.
1. 그럼 가장 상단에 있는 4한테 물어본다. 5는 4보다 큽니까? Yes
2. 위 질문에 Yes가 나왔으니 오른쪽으로 빠진다. 그리고 만난 6한테 물어본다. 6보다 작습니까? Yes
두 번의 질문으로 원하는 답을 찾게 된다.
결론은 데이터베이스에서 index를 만들라고 하면 트리형태로 위 그림처럼 만들어준다는 얘기다.
이를 전문용어로 Binary Search Tree라고 한다.
근데, 저기서 조금 더 개선시킬 방법이 보인다. 저 하나 하나의 카드를 Node라고 부르는데, 이 노드에 숫자 하나만을 담는게 아니라 숫자를 두 개씩 넣어버리면 데이터가 많아지면 많아질수록 더 시원하게 날려버릴 수 있지 않을까? 다음 그림처럼 말이다.
이렇게 한 노드에 여러 데이터를 넣어서 한번에 많은 양의 필요없는 데이터를 쳐낼 수 있다.
예를 들어 또 같은 질문이 들어왔다고 가정해보자.
Q: 저는 5가 어디 저장되어 있는지 알고 싶어요.
1. 4/8 이 들어있는 최상단 노드에 5보다 큰가?를 물어봤을 때 4는 No, 8은 Yes를 답하게 되니 중간 다리로 내려간다.
2. 6한테 5보다 작니?를 물어봤을 때 No를 답하니 5를 찾게 된다.
데이터가 위 사진보다 더 많아졌음에도 불구하고 똑같이 2번의 질문만으로 답을 찾아낼 수 있게 된다.
근데, 여기서 또 다른 방식이 있다. B+Tree라는 구조인데 이건 다음과 같이 생겼다.
이 구조는 데이터는 전부 가장 밑바닥에 존재하고 (여기서 가장 하단의 노드를 가리키는 말로 '리프노드'라고한다) 가이드 라인만 제공하는 형식이다. 이렇게 되더라도 여전히 같은 맥락으로 절반씩 쳐내는 게 가능하다. 똑같이 2번의 질문 만으로 데이터를 찾아낼 수 있다.
근데 이 B+Tree의 다른점은 하단에 데이터끼리도 연결을 해 둔다는 것이다.
이렇게 하단에도 연결을 해두면 뭐가 좋을까? 범위 검색이 쉬워진다. 예를 들어 4부터 8까지의 데이터를 가져오고 싶으면 연결된 선으로 4부터 8까지 쭉 가져오기만 하면 된다. 4만 찾으면 말이지. B Tree랑 비교하면 훨씬 더 우월한 검색이 가능하다. B Tree로는 범위 검색 시 가지를 왔다리 갔다리 해야한다.
정리를 하자면
age = 20인 데이터를 찾아줘!
index가 없는 경우: 모든 행을 다 뒤져서 찾아냈을 때 돌려준다.
index가 있는 경우: 자동으로 index 컬럼부터 보고 적은 질문으로 더 빨리 찾아낸다. 찾아낸 후 인덱스에는 원래 행을 찾을 수 있는 주소가 있는데 그 주소를 통해 찾고자 하는 데이터를 돌려준다.
그러나, index가 장점만 있지는 않다.
index를 구현하면 어떤 단점이 있나? 같은 내용을 담는 컬럼을 복사한다. 그 말은 데이터베이스의 용량을 더 사용한다는 의미이다.
즉, index가 많아지면 많아질수록 더 많은 데이터베이스의 용량을 가져다 사용한다는 뜻이다. 또 다른 단점은 만약 원래 데이터베이스에 레코드가 삽입, 수정, 삭제가 된다면? 그 레코드를 인덱스에도 똑같이 삽입, 수정, 삭제의 과정을 겪어야 한다. (근데, 요즘은 뭐 컴퓨터 성능이 너무 좋아서 사실 이런것까지 고려할 필요가 있나 싶다)
참고: PK는 index 생성이 필요없다. 왜냐하면 자동으로 정렬이 되어 있기 때문에. 그리고 이를 clustered index라고 표현한다.