728x90
반응형
SMALL

테스트 자동화 인프라 구축 프로젝트를 여러번 진행해 오면서 알게된 내용과 필요한 내용을 정리하고자 한다.

 

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으로 작성된 시나리오를 실행 가능한 테스트로 전환하여, 요구사항이 제대로 충족되었는지를 자동으로 검증할 수 있게 해준다.

 

이러한 조합은 비즈니스 요구사항을 정확하게 이해하고, 이를 기반으로 고품질의 소프트웨어를 더 빠르게 개발하는 데 도움을 준다.

또한 사람이 읽을 수 있는 언어로 애플리케이션의 기능을 설명하듯 테스트 시나리오를 만들기 때문에  애플리케이션의 특정 기능에 대한 문서화가 대체될 수 있다는 점에서 장점을 가진다고 볼 수 있다.

그래서 결국 더 예측 가능하고 관리 가능하며 비즈니스 요구사항과 기술적 구현 사이의 간극을 줄이는 데 초점을 맞추고 있다.

 

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

6. Appium과 Cucumber를 이용해 UI Automation Testing  (0) 2024.04.17
5. 프로젝트 환경 설정  (2) 2024.04.17
4. Appium Inspector 연결  (0) 2024.04.17
3. APK 설치  (0) 2024.04.17
2. Appium  (0) 2024.04.17
728x90
반응형
SMALL

Hash TableKey/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).

728x90
반응형
LIST

'자료구조' 카테고리의 다른 글

배열과 Big O 표기법  (0) 2024.05.10
Binary Search Tree  (0) 2024.04.16
Tree와 BFS / DFS  (0) 2024.04.16
Stack  (0) 2024.04.15
Queue  (0) 2024.04.15
728x90
반응형
SMALL

Binary Search Tree는 진짜 중요한 개념이다.

Binary Tree를 저번 시간에 배웠고 이 Binary Tree를 다시 상기해보면 자식이 최대 2개까지만 있는 노드로만 구성되어 있는 트리를 Binary Tree라고 했고 이 트리를 탐색하는 방법 중 대표적인 두 가지 DFS, BFS가 있다고 했다.

 

DFS(Depth First Search)는 깊이 우선 탐색으로 밑으로 더 내려갈 수 없을때까지 계속 아래로 찾아나가는 방식.

BFS(Breadth First Search)는 한 노드의 모든 라인을 다 검색하고 그 바로 다음 아래 라인을 다 검색해가면서 찾아내는 방식이라고 했다.

 

Binary Search Tree

Binary Search Tree는 Binary Tree에 특정한 룰을 추가한 트리구조이다.

어떤 룰일까? 한 노드를 기준으로 왼쪽 Sub tree는 지금 노드보다 무조건 더 작은값, 오른쪽 Sub tree는 지금 노드보다 무조건 더 큰 값으로만 구성되어 있는 구조를 Binary Search Tree라고 한다.

이 트리는 완벽한 Binary Search Tree이다. 모든 노드를 기준으로 비교해도 왼쪽 Sub tree는 본인보다 작은 값을 가지고 있고, 오른쪽 Sub tree는 본인보다 큰 값을 가지고 있기 때문.

 

이 구조가 왜 좋고 왜 자주 사용될까? 절반 소거법(Binary Search), 다른말로 '분할 정복 방식'이 가능하기 때문에 효율적으로 원하는 값을 찾아낼 수 있기 때문이다.

예를 들어 저 트리에서 10을 찾고 싶다고 해보자. 다음 단계를 거쳐간다.

1단계

처음 루트 노드에게 10이거나 10보다 크니? 라고 물어봐서 10이면 끝. 10이 아닌 경우 10보다 작다고 말할 테니 오른쪽으로 이동.

여기서 오른쪽으로 이동하면 5를 기준으로 왼쪽은 볼 필요가 없어진다. 이게 절반 소거법의 장점이다.

2단계

그 다음 노드인 8에게 10이거나 10보다 큰지를 물어본다. 10이라면 끝. 10이 아닌 경우 10보다 작다고 말할테니 오른쪽으로 이동.

3단계

단 세번의 질문으로 원하는 10을 찾아냈다. 여기서 세번은 트리의 깊이(Depth)를 말한다. 

5번 노드가 있는 1 Depth.

2, 8번 노드가 있는 2 Depth.

10번 노드가 있는 3 Depth.

이렇게 트리의 깊이만큼만 질문해서 원하는 답을 찾아낼 수 있다.

 

Binary Tree vs Binary Search Tree

Binary Search Tree

N개의 노드를 가진 꽉 찬 트리의 경우 트리의 깊이는 log N이다.

그래서 이 Binary Search Tree 구조로 원하는 값을 찾을 때 시간 복잡도는 O(log N)이다.

 

Binary Tree

정렬된 트리가 아니라 원하는 값을 찾기 위해 DFS 또는 BFS를 통해 찾아낸다.

결국 최악의 경우 N개의 노드를 가진 트리에서 N의 값을 찾는 시간 복잡도는 O(N)이다.

따라서 Binary Search Tree가 더 빠른 속도로 원하는 값을 찾아낼 수 있다.

 

Binary Search Tree 코드로 구현해보기

struct Node* find_node(struct Node* root, int findValue) {
	if (root == NULL || root->value == findValue) {
    	return root;
    } else if (root->value > findValue) {
    	return find_node(root->left, findValue);
    } else {
    	return find_node(root->right, findValue);
    }
}

 

위 코드가 Binary Search Tree 구조에서 원하는 값을 찾아내는 코드이다. 역시 Recursion(자기 자신 참조)을 통해 간단하게 구현할 수 있다. 순서대로 한 번 접근해보자. 

 

1. 10을 찾고싶다고 할때, root node인 5가 최초로 들어온다.

2. root(5)는 NULL이 아니고 root(5)가 가진 값이 10이 아니므로 다음 if로 간다.

3. root(5)가 가진 값 5가 찾고자하는 값 10보다 크지 않기 때문에 다음 else로 간다.

4. 자기 자신을 호출한다 이때 파라미터는 root(5)의 오른쪽 노드 8과 찾고자하는 값 10.

5. 역시 root(8)는 NULL이 아니고 root(8)가 가진 값이 10이 아니므로 다음 if로 간다.

6. root(8)이 가진 값 8이 10보다 크지 않으므로 다음 else로 간다.

7. 자기 자신을 호출한다. 여기서 파라미터는 root(8)의 오른쪽 노드 10과 찾고자하는 값 10.

8. root(10)은 NULL이 아니지만, root(10)의 값 10과 찾고자 하는 값 10이 같으므로 이 root를 리턴한다.

9. 7번으로 돌아온다. 호출한 자기자신이 반환한 root(10)을 리턴한다.

10. 4번으로 돌아온다. 호출한 자기자신이 반환한 root(10)을 리턴한다. 끝.

 

이런식으로 절반을 소거해가면서 찾아내는 방법을 Binary Search라고 하고 그 구조가 Tree일 때 Binary Search Tree라고 한다.

 

 

Binary Search가 꼭 Tree여야하나?

그럼 여기서 질문이 생긴다. Binary Search는 꼭 트리일 필요는 없을 것 같다. 왜냐하면 배열에 값을 정렬해 놓아도 Binary Search로 검색할 수 있으니까. 맞다. 똑같이 시간 복잡도는 O(log N)이다.

이러한 정렬된 배열에 중간을 잡고 물어보면 똑같다. 

1단계

정렬된 배열에 중간에 10이거나 10보다 큰지 물어본다. 10이라면 끝. 10이 아니면 10보다 작으니 오른쪽만 보면 된다.

2단계

오른쪽을 기준으로 중간에서 10이거나 10보다 큰지 물어본다. 10이라면 끝. 10이 아니면 10보다 작으니 오른쪽만 보면 된다.

3단계

 

그럼 뭐하러 Binary Search Tree를 사용할까? 

정렬된 배열에 값이 새로 추가되거나 제거되는 경우가 없으면 정렬된 배열이 더 좋을 수 있다.

근데, 새롭게 추가되는 값이 있을 땐 그 값을 추가한 후 다시 정렬을 해야하는 과정이 필요하다.

예를 들어보자, 저 배열에 4를 추가하고 싶다고 해보자.

그럼 정렬을 위해 2와 5사이에 넣어야하고 그럼 5부터 마지막을 뒤로 전부 밀어야한다. 이 작업의 시간 복잡도는 O(N)이다.

근데! Binary Search Tree는 이 작업도 O(log N)이다.

"왜요?" Binary Search Tree에서 N개의 노드를 가진 꽉 찬 트리의 경우 깊이는 log N이라고 했고 그래서 트리에서 원하는 값을 찾을 때 질문의 횟수는 트리의 깊이(Depth)만큼이라고 했다.

그럼 원하는 자리를 찾는데 시간 복잡도는 O(log N).

원하는 자리를 찾았으니 추가하는(지우는) 시간 복잡도는 O(1). 

경우를 모두 하는 시간 복잡도는 O(log N) + O(1) = O(log N)

 

O(N)과 O(log N)을 비교하면 압도적으로 빠른 수치다. 예를 들어, 전 세계 인구가 80억명이면 log(80억명) = 32이다.

80억개의 경우의 수를 Binary Search Tree로 만들면 내가 원하는 값을 찾는데 32번만에 가능하단 얘기다. 

근데 O(N)은...? 최악의 경우.. 80억번..?? 

 

근데! 여기서 하나 더, 꽉 찬 트리가 아니면?

만약, Binary Tree는 Binary Tree인데 꽉 찬 트리가 아니라 이런 모양이면 어떻게 할까?

 

이것도 Binary Tree다. 자식이 최대 2개를 만족하고 오른쪽은 다 자신보다 크니까.

이런 경우 트리의 Depth만큼 찾아내는 시간 복잡도는 O(log N)이 아니라 O(N)이다. 

 

결론부터 말하면 이런 경우는 고려하지 않아도 된다. Self-Balancing Binary Search Tree라는게 있는데 저런 모양이 나올 수 없게 새 노드가 추가되거나 삭제될 때 Binary Search Tree가 항상 Balance가 되도록 보장해준다.

Self-Balancing Binary Search Tree의 종류로 AVL Tree, Red Black Tree 이런게 있고 이런게 있다는 것 정도만 알아두자.

 

728x90
반응형
LIST

'자료구조' 카테고리의 다른 글

배열과 Big O 표기법  (0) 2024.05.10
Hash Table  (2) 2024.04.16
Tree와 BFS / DFS  (0) 2024.04.16
Stack  (0) 2024.04.15
Queue  (0) 2024.04.15
728x90
반응형
SMALL

우선 BFS, DFS를 알기 전 Tree를 알아야 하는게 먼저더라.

Tree

트리란, 부모 자식 관계를 나타낼 수 있는 자료 구조다.

다음 그림이 트리라고 말 할 수 있다.

 

- 노드 하나에 한개의 Parent 노드가 있을 수 있다.

- 노드 하나에 여러개의 Child 노드가 있을 수 있다.

 

이런 유형의 자료구조는 실생활에서도 너무 많이 접해볼 수 있다. 

  • 회사 조직도
  • 가족 관계도
  • File System

 

이런 트리 중 특별한 트리가 있는데 'Binary Tree'라는 게 있다.

Binary Tree

한 노드에 Child 노드가 최대 2개까지만 있는 트리를 Binary Tree라고 한다.

위 그림은 그럼 Binary Tree라고 할 수 없다. 왜냐하면, 한 노드(Node 1)가 Child Node를 3개까지도 가지고 있으니까.

 

Binary Tree는 Linked List와 굉장히 유사하게 생겼다. Linked List의 구조는 다음과 같다.

그리고 이 Linked List의 한 노드를 코드로 표현한다면 다음처럼 표현할 수 있겠다.

struct Node {
    int value;
    struct Node *next;
}

 

 

 

그럼 바이너리 트리는 여기에 자식이 하나씩 더 있을 수도 있는것뿐.

다음 그림이 Binary Tree라고 표현할 수 있다.

이 Binary Tree의 한 노드를 코드로 표현한다면 다음처럼 작성할 수 있다.

struct Node {
    int value;
    struct Node *left_child;
    struct Node *right_child;
}

 

그래서, 이 바이너리 트리 구조를 메모리 상에서 어떻게 표현될지를 봐보자.

 

하나에 4 Byte 메모리 구조를 보자. 

 

Node 1은 값으로 1을 가지고 있고, left_child, right_child에 대한 각각의 주소를 가리키는 포인터를 가지고 있다.

 

Node 1의 left_child가 가리키는 주소를 따라가면 Node 2가 가진 값 2가 나온다. 

Node 1의 right_child가 가리키는 주소를 따라가면 Node 3가 가진 값 3이 나온다. 

 

Node 3은 자식이 없기 때문에 가리키는 포인터도 없다.

Node 2는 하나의 자식을 left_child가 가리키고 있고 그 주소를 따라가면 Node 4가 가지고 있는 값 4가 나온다.

Node 4도 자식이 없기 때문에 가리키는 포인터도 없다.

 

 

Tree, Binary Tree 둘 다 뭔지 알겠다. 그럼 이 트리를 어떻게 탐색할까? 이 탐색하는 방법이 앞에서 말한 DFS, BFS가 된다.

DFS (Depth First Search)

트리를 탐색하는 가장 일반적인 방법 중 하나인 깊이 우선 탐색(DFS)가 있다.

다음 트리를 보자.

DFS는 깊이 우선 탐색, 즉 한쪽 방향으로 탐색을 시작했으면 더 내려갈 수 없을때까지 아래로 내려가서 확인하는 것을 말한다.

 

그래서 Node1 부터 탐색을 시작하고 왼쪽 또는 오른쪽으로 하나 방향을 잡은 후 그 방향의 가장 하단까지 쭉 검색하고 다시 반대편으로 돌아오는 방식이라고 생각하면 된다. 

예시

1단계

2단계

3단계

 

3단계가 끝나고 더 내려갈 수 없을 만큼 내려왔으니 다시 Node 1으로 가서 왼쪽을 다 털었으니 오른쪽으로 털기 시작한다.

4단계

5단계

6단계

Node 3을 기준으로 왼쪽 끝까지 갔으니 이제 다시 Node 3의 오른쪽을 털면 된다.

 

7단계

 

그럼, DFS는 코드로 어떻게 구현할 수 있을까?

DFS 코드로 구현하기

DFS(Depth First Search)Recursion(자기 자신 참조)으로 간단하게 구현할 수 있다. 

난 JAVA를 좋아하니까 JAVA로 구현해보겠다.

 

TreeNode

package dfs.binarytree;

public class TreeNode {
    private final int value;
    private TreeNode left;
    private TreeNode right;

    public TreeNode(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    public TreeNode getLeft() {
        return left;
    }

    public TreeNode getRight() {
        return right;
    }

    public void setLeft(TreeNode left) {
        this.left = left;
    }

    public void setRight(TreeNode right) {
        this.right = right;
    }
}

 

BinaryTree

package dfs.binarytree;

public class BinaryTree {
    public static void dfs(TreeNode root) {
        if (root == null) {
            return;
        }

        System.out.println("node Value = " + root.getValue());

        dfs(root.getLeft());
        dfs(root.getRight());
    }
}

 

Main

package dfs.binarytree;

public class Main {
    public static void main(String[] args) {

        TreeNode nodeOne = new TreeNode(10);
        TreeNode nodeTwo = new TreeNode(20);
        TreeNode nodeThree = new TreeNode(30);
        TreeNode nodeFour = new TreeNode(40);
        TreeNode nodeFive = new TreeNode(50);
        TreeNode nodeSix = new TreeNode(60);
        TreeNode nodeSeven = new TreeNode(70);

        nodeOne.setLeft(nodeTwo);
        nodeOne.setRight(nodeThree);

        nodeTwo.setLeft(nodeFour);

        nodeThree.setLeft(nodeFive);
        nodeThree.setRight(nodeSix);

        nodeFive.setLeft(nodeSeven);

        BinaryTree.dfs(nodeOne);
    }
}

실행결과:

node Value = 10
node Value = 20
node Value = 40
node Value = 30
node Value = 50
node Value = 70
node Value = 60

 

코드를 보면 굉장히 간단하지만 이 코드가 DFS를 구현한 코드이다.

아래 그림을 가지고 위 코드를 돌려보는 시뮬레이션을 해보자.

 

1. [root = Node 1], root는 NULL이 아니니까 다음 라인으로 가서 printf호출 후 자기 자신을 호출하는데 주는 파라미터가 왼쪽 Child.

2. [root = Node 2], root는 NULL이 아니니까 다음 라인으로 가서 printf호출 후 자기 자신을 호출하는데 주는 파라미터가 왼쪽 Child.

3. [root = Node 4], root는 NULL이 아니니까 다음 라인으로 가서 printf호출 후 자기 자신을 호출하는데 주는 파라미터가 왼쪽 Child.

4. [root = NULL], root가 NULL이므로 return

5. 3으로 돌아옴. 자기 자신을 호출하는데 주는 파라미터가 오른쪽 Child.

6. [root = NULL], root가 NULL이므로 return

7. 3번으로 돌아옴 모든 라인이 끝났으니 return

8. 2번으로 돌아옴. 자기 자신을 호출하는데 주는 파라미터가 오른쪽 Child.

9. [root = NULL] root가 NULL이므로 return

10. 2번으로 돌아옴. 모든 라인이 끝났으니 return

11. 1번으로 돌아옴. 자기 자신을 호출하는데 주는 파라미터가 오른쪽 Child.

12. [root = Node 3], root는 NULL이 아니니까 다음 라인으로 가서 printf 호출 후 자기 자신을 호출하는데 주는 파라미터가 왼쪽 Child.

13. [root = Node 5], root는 NULL이 아니니까 다음 라인으로 가서 printf 호출 후 자기 자신을 호출하는데 주는 파라미터가 왼쪽 Child.

14. [root = Node 7], root는 NULL이 아니니까 다음 라인으로 가서 printf 호출 후 자기 자신을 호출하는데 주는 파라미터가 왼쪽 Child. 

15. [root = NULL], root가 NULL이므로 return

16. 14번으로 돌아옴 자기 자신을 호출하는데 주는 파라미터가 오른쪽 Child.

17. [root = NULL], root가 NULL이므로 return

18. 14번으로 돌아옴. 모든 라인이 끝났으니 return

19. 13번으로 돌아옴. 자기 자신을 호출하는데 주는 파라미터가 오른쪽 Child.

20. [root = NULL], root가 NULL이므로 return

21. 13번으로 돌아옴. 모든 라인이 끝났으니 return

22. 12번으로 돌아옴. 자기 자신을 호출하는데 주는 파라미터가 오른쪽 Child.

23. [root = Node 6], root는 NULL이 아니니까 다음 라인으로 가서 printf 호출 후 자기 자신을 호출하는데 주는 파라미터가 왼쪽 Child.

24. [root = NULL], root가 NULL이므로 return

25. 23번으로 돌아옴. 자기 자신을 호출하는데 주는 파라미터가 오른쪽 Child.

26. [root = NULL], root가 NULL이므로 return

27. 23번으로 돌아옴. 모든 라인이 끝났으니 return 

28. 12번으로 돌아옴. 모든 라인이 끝났으니 return

29. 1번으로 돌아옴. 모든 라인이 끝났으니 return

 

이렇게 Recursion을 통해 DFS를 구현할 수 있다. 이제 BFS를 알아보자.

BFS (Breadth First Search)

BFS(Breadth First Search)는 넓이 우선 탐색으로 이것 역시 트리를 검색하는 방법 중 대표적인 하나이다.

이건 DFS랑 어떻게 다른지 그림을 통해 알아보자.

 

0단계

가장 최상위 노드인 Node 1을 검색한 후 다음 한 칸 밑에 있는 모든 노드를 쭉 털고, 그 다음 한 칸 밑에 있는 모든 노드를 쭉 털고, 이렇게 한 라인을 쭉 터는식으로 검색하는 방식이다.

1단계

2단계

3단계

4단계

5단계

6단계

 

이렇게 트리의 한 라인 한 라인을 쭉 터는 방식으로 검색하는게 BFS라고 생각하면 된다.

이 BFS는 코드로 어떻게 구현할까?

 

BFS 코드로 구현하기 

BFS(Breadth First Search)Queue로 간단하게 구현할 수 있다.

Queue는 저번 포스팅에서 배웠지만 FIFO구조. 그리고 Queue는 처음과 끝을 알아야하는 자료구조이다. 

 

코드로 어떻게 구현하는지 살펴보자.

void bfs(struct Node* root) {
	if (root == NULL) {
    	return;
    }
    
    struct TreeNode* queue[10000];
    int front = 0;
    int rear = 0;
    queue[rear++] = root;
    while (front != rear) {
    	struct TreeNode* curr = queue[front++];
        printf("%d ", curr->val); // 실제 작업을 여기서 하면됨, 지금은 예제니까 출력을 함
        if (curr->left != NULL) {
        	queue[rear++] = curr->left;
        }
        if (curr->right != NULL) {
        	queue[rear++] = curr->right;
        }
    }
}

예제에서 Queue 사이즈는 어느정도 크게 설정했다. 어떤 문제를 푸냐에 따라 달라지겠지만 지금으로선 10000으로 충분하기 때문에.

이것도 그림과 같이 한단계씩 알아보자.

 

[root = Node 1]. root는 NULL이 아니다.

- queue를 만든다.

- Queue를 만들고 rear에 root(Node1)를 넣고 rear를 하나 추가한다. 

- front와 rear는 같지 않기 때문에 while 루프를 돌기 시작한다.

- 여기서 queue의 front값인 Node1을 curr에 넣고, front를 하나 추가한다.

- curr의 값 1을 찍는다. 

- Node1의 left child(Node2)가 NULL이 아니니까 rear 자리에 left child를 넣고 rear를 하나 추가한다.

- Node1의 right child(Node3)가 NULL이 아니니까 rear자리에 right child를 넣고 rear를 하나 추가한다.

- 루프가 끝나고 역시 front와 rear는 다르니까 계속 돈다.

- curr에 front가 가리키는 Node2를 넣고 front를 하나 추가한다.

- curr의 값 2를 찍는다.

- Node2의 left child(Node4)가 NULL이 아니니까 rear 자리에 left child를 넣고 rear를 하나 추가한다.

- Node2의 right child는 NULL이니까 아무것도 하지 않고 루프가 끝난다.

- front와 rear가 다르니까 루프는 계속된다.

- 현재 front의 node(Node3)를 curr에 넣고 front를 하나 추가한다.

- curr의 값 3을 찍는다.

- Node3의 left child(Node5)가 NULL이 아니니까 rear에 left child를 넣고 rear를 추가한다.

- Node3의 right child(Node6)가 NULL이 아니니까 rear에 right child를 넣고 rear를 추가한다.

- 루프가 끝나고 다시 front와 rear는 다르기 때문에 또 돈다.

- 현재 front가 가리키는 Node4를 curr에 집어넣고 front를 하나 추가한다.

- curr의 값 4를 찍는다.

- curr의 left child와 right child모두 NULL이니까 아무것도 하지 않고 다음 루프로 간다.

- front와 rear는 다르니까 루프가 계속된다.

- 현재 front에 담긴 Node5를 curr에 담고 front를 하나 추가한다.

- curr의 값 5를 찍는다.

- curr의 left child(Node7)는 NULL이 아니니까 rear에 넣고 rear를 하나 추가한다.

- curr의 right child는 NULL이니까 다음 루프로 간다.

- 여전히 front와 rear는 다르니까 루프는 계속된다.

- 현재 프론트에 담긴 Node6을 curr에 담고 front를 하나 증가시킨다.

- curr의 값 6을 찍는다.

- curr의 left, right child가 모두 NULL이므로 아무것도 하지않고 다음 루프로 간다.

- 여전히 front와 rear는 다르기 때문에 루프는 계속된다.

- 현재 front가 가리키는 Node7을 curr에 넣고 front를 하나 추가한다.

- curr의 값 7을 찍는다.

- curr의 left, right child 모두 NULL이니까 루프가 끝난다.

- 루프의 조건 front != rear를 만족하지 않으므로 루프는 끝난다. 

 

정리

DFS, BFS 모두 트리를 탐색할 때 방법 중 하나이다. DFS(Depth First Search)는 깊이 우선 탐색, BFS(Breadth First Search)는 넓이 우선 탐색의 줄임말로 둘 다 어떻게 탐색하는지 꼭 알아두면 좋을것같다.

 

728x90
반응형
LIST

'자료구조' 카테고리의 다른 글

Hash Table  (2) 2024.04.16
Binary Search Tree  (0) 2024.04.16
Stack  (0) 2024.04.15
Queue  (0) 2024.04.15
Array vs Linked List  (0) 2024.04.15
728x90
반응형
SMALL

Stack은 위에만 구멍이 뚫린 박스라고 생각하면 된다.

이 그림이 Stack 구조이다.

먼저 들어간 데이터가 가장 마지막에 나온다.

가장 마지막에 들어간 데이터가 가장 먼저 나온다. LIFO.

 

Stack은 중간에 데이터를 넣는것도 불가능하고 맨위에 데이터를 새로 넣거나 맨위에 있는 데이터를 빼거나해야 한다.

 

코드적으로 접근해보기

Stack은 보통 이러한 메서드들을 가지고 있다.

  • pop(): 가장 최근에 추가된 데이터 빼기
  • push(item): 데이터 새로 추가하기
  • peek(): 가장 최근에 추가된 데이터 읽기
  • isEmpty(): Stack이 비어있는지 확인하기

다음 그림과 같이 사이즈가 7인 Stack이 있다고 해보자.

 

1. 이 상태에서 push(8)을 하면 다음 그림이 된다.

2. 이 상태에서 또 push(10)을 하면 다음 그림이 된다.

3. 이 상태에서 peek() 메서드를 호출하면 10을 반환한다. 가장 마지막에 넣은 녀석을 가져오는 메서드니까.

4. 이 상태에서 isEmpty() 메서드를 호출하면 false를 반환한다.

5. 이 상태에서 pop()을 호출하면 10이 나온다.

6. 이 상태에서 pop()을 한번 더 호출하면 8이 나온다.

7. 여기서 isEmpty()를 호출하면 true가 나온다.

 

그래서 Stack의 특징은 맨 위(가장 마지막)만 알면 모든 처리가 가능하다.

 

Stack의 예시 코드

#define MAX_SIZE 100

typedef struct {
	int stack[MAX_SIZE];
    int pop;
} Stack;

void initialize(Stack* s) {
	s->top = -1; // 최초에는 아무 데이터도 들어가지 않으니까 -1
}

int pop(Stack* s) {
	if (isEmpty(s)) {
    	printf("Stack Underflow: Cannot pop element from the stack.\n");
        return -1;
    }
    
    int item = s->stack[s->top--];
    return item;
}

void push(Stack* s, int item) {
	if (s->top == MAX_SIZE -1) {
    	printf("Stack Overflow: Cannot push element onto the stack.\n");
        return;
    }
    
    s->stack[++s->top] = item;
    printf("%d pushed onto the stack.\n", item);
}

int peek(Stack* s) {
	if (isEmpty(s)) {
    	printf("Stack is empty.\n");
        return -1;
    }
    
    return s->stack[s->top];
}

bool isEmpty(Stack* s) {
	return (s->top == -1);
}

 

Stack test

int main() {
    Stack stack;
    initialize(&stack);
    ...
}

여기까지 하면 이런 그림이 된다.

 

int main() {
    Stack stack;
    initialize(&stack);
    push(&stack, 10);
    ...
}

여기까지 하면 이런 그림이 된다.

int main() {
    Stack stack;
    initialize(&stack);
    push(&stack, 10);
    push(&stack, 20);
    push(&stack, 30);
    ...
}

여기까지 하면 이런 그림이 된다.

이 상태에서 peek()메서드를 호출하면? 30을 반환한다.

 

다음과 같이 pop()메서드를 호출하면 어떻게 될까?

int main() {
    Stack stack;
    initialize(&stack);
    push(&stack, 10);
    push(&stack, 20);
    push(&stack, 30);
    
    pop(&stack);
    ...
}

이런 그림이 된다.

 

정리

이런 자료구조를 Stack이라고 한다. 그럼 자주 들어봤던 Stack Overflow는 어떤 순간에 발생하는걸까?

일단 Overflow니까 꽉 차서 더 못넣는다는 얘기인데, 자바 메모리 구조를 생각해보면 항상 실행하는 메서드를 순차적으로 Stack에 집어넣게 되어 있다.

 

최초의 main() 메서드가 실행되고 main()에서 A()라는 메서드를 호출하고 A()메서드가 B()라는 메서드를 호출하면 처리하는 순서는 B -> A -> main이 된다. 이게 정확하게 Stack의 구조다.

 

그래서 이 Stack에 계속해서 쌓이는 무언가가 Stack Overflow를 일으키는데 대표적인 예시가 자기 자신을 무한하게 호출하는 경우가 그렇다. 자기가 자기 자신을 계속 호출 호출 호출 무한루프에 빠지는거지. 그럴때 이제 더 이상 Stack에 넣을 자리가 없어서 Stack Overflow가 발생한다.

 

728x90
반응형
LIST

'자료구조' 카테고리의 다른 글

Hash Table  (2) 2024.04.16
Binary Search Tree  (0) 2024.04.16
Tree와 BFS / DFS  (0) 2024.04.16
Queue  (0) 2024.04.15
Array vs Linked List  (0) 2024.04.15
728x90
반응형
SMALL

이번엔 Queue를 정리해보자.

Queue는 위 아래 모두 구멍이 있는 박스라고 생각을 하면 된다.

그림을 보면 데이터는 위에서만 넣을 수 있고 빠지는 건 아래에서부터 빠진다.

이런 구조라면 먼저 들어간 데이터가 먼저 나오는 구조가 된다.

QueueFIFO(First In First Out) 구조이다.

 

코드적으로 생각해보기

Queue가 가지고 있는 보통의 대표적인 메서드는 다음과 같은 것들이 있다.

  • enqueue(item): 데이터 추가하기
  • dequeue(): 가장 오래된 데이터 빼기
  • peek(): 가장 오래된 데이터 보기
  • isEmpty(): Queue가 비었는지 확인하기

 

비어있는 사이즈가 7인 큐가 있다.

 

1. 이 상태에서 데이터 1을 넣으면 다음 모양이 된다.

 

2. 그리고 데이터 2를 또 추가적으로 넣는다면 이런 모양이 된다.

 

이 상태에서 peek() 메서드를 호출하면 결과는 1이다.

가장 먼저 넣은 데이터가 가장 오래된 데이터.

이 상태에서 isEmpty() 메서드를 호출하면  결과는 false다.

 

3. 데이터를 빼보자. 가장 먼저 들어간 1이 나온다.

4. 또 빼보자. 그 다음으로 들어간 2가 나온다.

 

 

이런 구조가 큐이고 가장 먼저 들어간 녀석을 빼는 작업과, 비었는지 안 비었는지를 확인할 수 있으려면 큐 구조는 시작과 끝을 알고 있어야 한다.

 

struct Queue

#define MAX_SIZE = 100

struct queue {
	int data[100];
    int front, rear;
}

void init_queue(struct queue *q) {
	q->front = q->rear = 0;
}

void enqueue(struct queue *q, int value) {
	if (q->rear == 100) {
    	printf("Queue is full.\n");
        return;
    }
    q->data[q->rear++] = value;
}

int dequeue(struct queue *q) {
	if (q->front == q->rear) {
    	print("Queue is empty\n");
        return -1;
    }
    return q->data[q->front++];
}

int is_empty(struct queue *q) {
	return q->front == q->rear;
}

int peek(struct queue *q) {
	if (q->front == q->rear) {
    	printf("Queue is empty\n");
        return -1;
    }
    return q->data[q->front];
}

 

 

Queue test

int main() {
    struct queue q;
    init_queue(&q);
	...
}

이 코드를 수행하면 다음과 같은 그림이 된다.

int main() {
    struct queue q;
    init_queue(&q);
    enqueue(&q, 1);
    ...
}

이 코드를 수행하면 큐에 1을 넣는다. 그러면 가장 오래된 데이터를 가르키는 위치와 다음에 넣을 수 있는 위치에 대한 정보가 변한다.

다음 그림처럼.

int main() {
    struct queue q;
    init_queue(&q);
    enqueue(&q, 1);
    enqueue(&q, 2);
    enqueue(&q, 3);
    ...
}

이렇게까지 코드가 수행되면 다음 그림이 된다.

int main() {
    struct queue q;
    init_queue(&q);
    enqueue(&q, 1);
    enqueue(&q, 2);
    enqueue(&q, 3);
    int value = dequeue(&q);
    ...
}

이제 dequeue() 메서드를 호출한다. 가장 오래된 데이터(1)가 나오게 된다. 그리고 가장 오래된 데이터를 큐에서 뺐으니 가장 오래된 데이터를 가르키는 포인터는 변경된다.

만약 이 상태에서 peek() 메서드를 호출하면 나오는 값은 2가 된다. 현재 front가 가르키는 값이 2니까.

만약 이 상태에서 is_empty() 메서드를 호출하면 결과는 false다.

 

이런것을 라고 한다.

 

 

큐에서 발생할 수 있는 문제점

다음 그림을 보자.

이렇게 사이즈가 7인 큐에 값을 계속 넣었고 마지막 자리까지 넣었다. 그럼 꽉 찬 상태라 더 넣을 수 없다. 

이 모양일 때 dequeue()를 2번 수행했다고 해보자.

이런 그림이 될 것이다. 그럼 큐는 이제 꽉 차지 않았다. 이때 큐에 넣을 수 있을까? rear가 마지막 자리라 넣을 수가 없다. 

이런 문제를 어떻게 해결할까? Circular Queue.

 

Circular Queue는 이 문제를 어떻게 해결할까? rear 포인터가 밖으로 벗어나면 맨 앞으로 다시 가져오는 방법이다.

 

이런 그림이 될 수 있도록.

그렇게 또 다시 채울 수 있게 되고 두번을 더 채우다 보면 다음 그림이 된다.

이렇게 다시 front, rear 포인터가 같은 자리를 가르키면 꽉 찼다는 걸 알 수 있게 된다.

rear, front를 다시 회전시키는 방법은 간단하다.

rear = (rear + 1) % queue_size
front = (front + 1) % queue_size

rear 포인터가 6이 되어서 더 채울 수 없는 공간까지 오면 rear + 1은 7이 된다.

그걸 큐의 사이즈(7)로 나눈 나머지는 0이니까 다시 0을 가리키게 된다.

front 포인터도 같은 방식.

 

Queue 활용

그래서 큐는 대표적으로 Graph / Tree에서 BFS할 때 사용한다.

728x90
반응형
LIST

'자료구조' 카테고리의 다른 글

Hash Table  (2) 2024.04.16
Binary Search Tree  (0) 2024.04.16
Tree와 BFS / DFS  (0) 2024.04.16
Stack  (0) 2024.04.15
Array vs Linked List  (0) 2024.04.15
728x90
반응형
SMALL

오늘부터 자료구조에 대한 내용을 하나씩 정리하기로 했다.

기록을 하지 않으면 알아도 금방 까먹기 때문에 까먹더라도 다시 돌아갈 수 있게.

 

Array

배열은 메모리에 정해진 사이즈만큼 공간이 만들어져서 데이터를 저장하는 방식이다.

그래서 데이터는 연속적으로 위치한 메모리에 저장되어 있다.

 

장점

  • 연속적으로 메모리에 위치해있기 때문에 인덱스를 통해 빠르게 내가 원하는 값을 찾아낼 수 있다.

단점

  • 사이즈가 정해졌기 때문에 정해진 사이즈 이상으로 데이터를 저장할 수 없다.

Linked List

Linked List는 사이즈가 동적으로 할당된다.

메모리 상에 여기저기 흩뿌려져 있는 공간에 원하는 만큼 데이터를 넣을 수 있는 방식이다.

각 데이터는 다음 데이터의 주소를 알고 있는 포인터가 있어서 연결되어 있다.

 

장점

  • 사이즈가 정해진게 아니기 때문에 원하는 만큼 데이터를 추가할 수 있다.

단점

  • 메모리 상에 여기저기 흩뿌려져 있기 때문에 데이터를 찾아내려면 처음부터 하나씩 연결된 정보를 찾아 찾아가야 한다.

정리

그래서 둘을 비교하자면 속도 측면에서는 Array가 유리할 수 있다.

저장되는 위치가 연속적이기 때문에 인덱스로 바로 찾아낼 수 있기 때문.

 

데이터를 동적으로 계속해서 저장하기엔 Linked List가 유리할 수 있다.

사이즈가 고정적이지 않기 때문.

 

각 장단점을 파악한 후 적절하게 쓰임새에 맞게 사용하면 될 것 같다.

 

시간복잡도와 공간복잡도

Arrays의 시간 복잡도

  • Read: O(1) (특정 인덱스 값을 한번 읽으면 끝)
  • Write: O(1) (특정 인덱스에 값을 한번 추가하면 끝)

Arrays의 공간 복잡도

  • 배열에 N개의 엔트리가 있다고 했을 때
    • 메모리 사용량
      • 저장하는 타입의 메모리 크기를 C라고 했을 때 C * N = O(N)

Linked List의 시간 복잡도

  • Read: O(N) (N번째 원소를 읽기 위해서 시작부터 하나씩 읽어 나가 N번을 찾아야 한다)
  • Write: O(N) (마지막에 원소를 넣어야하고 마지막을 찾기 위해 역시나 시작부터 하나씩 읽어가 마지막에 가서 쓰기를 해야한다 마지막 원소를 찾아내는데 O(N), 쓰기 O(1) 따라서 O(N)

Linked List의 공간 복잡도

각 노드가 데이터+포인터를 가지므로 하나의 노드엔 데이터 + 포인터의 공간을 차지. 따라서 N개의 노드를 가진 Linked List는 O(N).

 

참고: Linked List가 이중 연결 리스트(앞과 뒤 모두 포인터가 있는)여도 O(N). 왜냐하면 노드 수에 비례하기 때문.

 

728x90
반응형
LIST

'자료구조' 카테고리의 다른 글

Hash Table  (2) 2024.04.16
Binary Search Tree  (0) 2024.04.16
Tree와 BFS / DFS  (0) 2024.04.16
Stack  (0) 2024.04.15
Queue  (0) 2024.04.15
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

클래스 안에 클래스를 중첩해서 정의할 수 있는데, 이것을 중첩 클래스(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();
        ...
    }
}

 

익명 내부 클래스는 저렇게 클래스를 선언하고 사용하는 게 아니라, 추상 클래스나 인터페이스 같은것을 상속받은 클래스가 필요한데 이걸 굳이 클래스 파일로 만들어서 클래스라는 키워드를 사용해서 만드는게 아니라 한 번 사용하고 말거 같을 때 코드 내에서 선언할 수가 있다.

 

익명 내부 클래스 예시

context.execute(new Strategy() {
    @Override
    public void call() {
        log.info("비즈니스 로직 1 실행");
    }
});

 

그럼 정적 중첩 클래스와 내부 클래스를 분리하는 이유는 뭘까?

 

정적 중첩 클래스와 내부 클래스의 차이

정적 중첩 클래스

  • static이 붙는다.
  • 바깥 클래스의 인스턴스에 소속되지 않는다.

내부 클래스

  • static이 붙지 않는다.
  • 바깥 클래스의 인스턴스에 소속된다.
그러니까 엄밀히 말하면 정적 중첩 클래스내부 클래스는 완전히 다른 거고 내부 클래스정적 중첩 클래스라고 말하거나 정적 중첩 클래스내부 클래스라고 말하면 안된다. 근데 말하면서 그냥 섞어 쓰니까 상황과 문맥에 따라 잘 이해해서 받아들이면 된다.

 

 

중첩 클래스는 언제 사용할까?

내부 클래스를 포함한 모든 중첩 클래스는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나, 둘이 아주 긴밀하게 연결되어 있는 경우에만 사용해야 한다. 외부의 여러 클래스가 특정 중첩 클래스를 사용한다면 중첩 클래스로 만들면 안된다.

 

중첩 클래스를 사용하는 이유

  • 논리적 그룹화: 특정 클래스가 다른 하나의 클래스 안에서만 사용되는 경우 해당 클래스 안에 포함하는 것이 논리적으로 더 그룹화된다. 패키지를 열었을 때 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는 장점도 있다.
  • 캡슐화: 중첩 클래스는 바깥 클래스의 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();
    }
}

외부 클래스와 내부 클래스에서 같은 이름으로 변수를 만들었을 때 접근하는 방법이다.

근데, 이렇게 변수 이름을 모호하게 작성 안하는게 가장 좋은 방법이다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

익명 클래스  (0) 2024.04.21
지역 클래스  (0) 2024.04.21
Stream API  (2) 2024.04.07
날짜와 시간  (0) 2024.04.04
Enum  (0) 2024.04.03
728x90
반응형
SMALL

요즘 Stream API를 사용하면서 얻게 되는 코드 가시성 향상과 코드 라인 간소화가 너무 재밌어진다. 그래서 이것 또한 JAVA에서 기본이 되는 내용 같아서 정리를 하고자한다.

Stream API가 필요한 이유

만약, 다음과 같은 배열이 주어졌다면 그리고 그 배열에 어떤 정렬이 필요하다고 할 때 Stream을 사용하지 않고 처리해보자.

String[] names = {"choi", "kim", "park", "sung", "jang"};

이런 배열이 있을 때, 오름차순으로 정렬을 하고 싶다. 그러면 다음과 같은 메서드를 사용해볼 수 있다.

Arrays.sort(names);

 

그리고 이것을 사용해서 실제로 배열을 출력해보기 위해 다음과 같은 코드를 작성한다. 

String[] names = {"kim", "choi", "park", "sung", "jang"};
        
Arrays.sort(names);

for (String name : names) {
    System.out.println(name);
}

 

실행결과:

choi
jang
kim
park
sung

 

원하는대로 잘 정렬이 됐다. 이게 이 요구사항을 처리할 때 나쁜 코드라고 말할 순 없을 것이다. 

근데 이것은 2가지 단점이 있다.

  • 원본 데이터가 변경된다.
  • 더 복잡한 요구사항이 있다면 로직을 작성했을 때 코드의 간결성이 떨어질 수 있다.

실제로 기존에 names 배열은 이제 없고 정렬된 배열로 변경된다. 위 코드를 보면 반환값을 찍은게 아닌 그대로 "names"를 찍었다.

 

그래서 이를 더 간결화하기 위해 Stream API를 사용한 코드를 보자.

Stream API 사용

Arrays
    .stream(names)
    .sorted()
    .forEach(System.out::println);

 

실행결과:

choi
jang
kim
park
sung

 

저 한 줄로 원하는 결과를 출력할 수 있게 됐다. 그리고 가장 큰 장점은 기존의 배열에는 영향이 없다는 사실이다.

실제로 그런지 아래 코드를 실행해보자.

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    Arrays.stream(names).sorted().forEach(System.out::println);

    System.out.println("names = " + Arrays.toString(names));
}

 

실행결과:

choi
jang
kim
park
sung
names = [kim, choi, park, sung, jang]

 

Stream의 특징

  • 원본의 데이터를 변경하지 않는다.
  • 일회성이므로 한 번 사용하면 다시 사용하지 못한다.

 

어? 원본의 데이터를 변경하지 않는다는 것은 알았는데, 일회성이란 말은 무슨말일까? 다음 코드를 보자.

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    Stream<String> streamNames = Arrays.stream(names);

    streamNames.sorted().forEach(System.out::println);

    System.out.println("firstValue = " + streamNames.findFirst());
}

 

streamNames 라는 Stream을 만들고, 그 녀석으로 오름차순 정렬을 수행한 뒤 루프를 돌려 하나씩 출력하는 코드를 진행 후에 다시 그 스트림을 가지고 첫번째 값을 찍어내는 시도를 했다. 실행 결과는 다음과 같다.

 

보는것과 같이 에러가 발생했다. 에러 내용은 스트림이 이미 한 번 작동됐고 더 사용할 수 없게 닫혀진 상태다라는 내용이다. 이렇듯 스트림은 한번 사용한 후 다시 재사용할 수 없다. 이것을 반드시 알아두어야 한다.

 

 

Stream의 여러 기능 중 절대적으로 중요한 것들

많은 기능이 있는데 개인적으로 filter(), map(), sorted(), distinct() 정도는 알아야 할 것 같다.

 

filter()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    List<String> filteredList = 
            Arrays.stream(names)
                    .filter(s -> s.length() >= 4)
                    .toList();
    System.out.println("filteredList = " + filteredList);
}

 

말 그대로다. 어떤 솔팅과정을 해주는 메서드이고, 다음과 같이 길이가 4이상인 문자열만 뽑아내고 싶다는 요구사항에 맞게 코드를 작성해주면 다음과 같은 결과를 얻을 수 있다.

filteredList = [choi, park, sung, jang]

 

map()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    List<String> upperCases = Arrays.stream(names).map(String::toUpperCase).toList();
    System.out.println("upperCases = " + upperCases);
}

 

map()은 기존의 Stream 요소들을 변환하여 새로운 Stream을 만들어내는 메서드이다. 위 코드는 요소 하나하나를 모두 대문자로 변경하고 리스트로 변환하는 경우이다. 출력하면 다음과 같은 결과를 얻을 수 있다.

upperCases = [KIM, CHOI, PARK, SUNG, JANG]

 

 

sorted()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    List<String> sorted = Arrays.stream(names).sorted().toList();
    System.out.println("sorted = " + sorted);
}

정렬을 할 때 사용되는 sorted(). 실행 결과는 다음과 같다.

sorted = [choi, jang, kim, park, sung]

 

 

distinct()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "choi", "choi"};

    List<String> sorted = Arrays.stream(names).distinct().toList();
    System.out.println("sorted = " + sorted);
}

중복 제거를 위해 사용되는 distinct(). 실행 결과는 다음과 같다.

sorted = [kim, choi, park, sung]

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

지역 클래스  (0) 2024.04.21
중첩 클래스(정적 중첩 클래스, 내부 클래스)  (0) 2024.04.14
날짜와 시간  (0) 2024.04.04
Enum  (0) 2024.04.03
Class 클래스  (0) 2024.04.03
728x90
반응형
SMALL

데이터베이스에서 빼놓을 수 없는 개념인 Index. 이 내용을 정리해보고자 한다. 

우선 다음과 같이 데이터베이스에 데이터가 저장되어 있다고 가정해보자.

 

그리고 질문은 다음과 같다.

Q: age = 20인 행을 찾으려면?

 

간단한 쿼리를 통해 가져올 수 있다.

SELECT * FROM XXX WHERE age = 20;

 

이 쿼리를 날리면 컴퓨터는 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를 만들까?

그렇다면 다음과 같이 그냥 ArrayLinked 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라고 표현한다.

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] 예외  (0) 2024.12.05
[Renewal] 스프링의 트랜잭션  (0) 2024.11.24
[Renewal] 트랜잭션, DB 락 이해  (0) 2024.11.22
[Renewal] 커넥션풀과 DataSource 이해  (2) 2024.11.21
[Renewal] JDBC 이해  (2) 2024.11.20

+ Recent posts