본문으로 바로가기

CS 16일차

category CS 공부/CS Study 원본 2021. 2. 15. 22:45

세그먼트 트리(Segment Tree)란?

배열에 부분 합을 구할 때 사용하는 개념입니다. 이 때 문제는 배열의 값이 지속적으로 바뀔 수 있기 때문에 매 순간 배열의 부분 길이 만큼, 즉 O(N) 만큼의 시간이 걸리기 때문에 이를 트리로 구현하여 O(logN) 의 시간으로 해결하는 방법입니다.

 

 

배열을 세그먼트 트리로

세그먼트 트리 를 사용하기 위해서는 주어진 배열을 이진 트리 구조로 만들어야 합니다. 이 때 트리를 구현하는 알고리즘은 다음과 같습니다.

  1. 부모노드의 값은 양 쪽 자식 노드 값의 합
  2. 배열의 요소들은 리프 노드에 위치

위 알고리즘을 통해 N = 10 일 때의 세그먼트 트리를 그리면 다음과 같습니다.

 

이 때 기존 데이터의 배열의 크기를 통해서 트리 배열의 최대 크기를 알 수 있습니다. 기존 데이터 배열의 크기를 N 이라 하면, 리프 노드의 개수가 N 이 되고, 트리의 높이 H 는 [ logN ] 이 되고, 배열의 크기는 2^(H+1) 이 됩니다.

 

트리는 단순히 1차원 배열과 인덱싱을 통해 구현할 수 있습니다. 트리의 각 노드 별 배열의 인덱스는 아래 그림과 같습니다.

#include <iostream>
#include <cmath>

using namespace std;

long long *tree;
long long A[10] = {1,2,3,4,5,6,7,8,9,10};

long long init(int index, int start, int end){
    if (start == end)
        tree[index] = A[start];
    else{
        int mid = (start+end)/2;
        tree[index] = init(index*2+1, start, mid) + init(index*2+2, mid+1, end);
    }
    return tree[index];
}

int main(int argc, const char * argv[]) {

    int N = 10;
    int h = ceil(log2(N));

    tree = new long long[1<<(h+1)];

    init(0,0,N-1);

    for (int i=0;i<1<<(h+1);i++){
        cout << tree[i] << "\n";
    }

    return 0;
}

위 그림과 약간의 차이점은 그림은 처음 인덱스를 1부터 시작한데 비해 구현할 때는 0부터 시작하였습니다. 현재 노드의 인덱스 index 에 대해 왼쪽 자식 노드의 인덱스는 index*2+1, 오른쪽 자식 노드의 인덱스는 index*2+2 로서 재귀적으로 구현하였습니다.

 

구간의 합 구하기

세그먼트 트리를 구현하였으니 이를 이용해서 본래의 목적, 구간의 합을 구해보도록 하겠습니다. 두 구간에 대해서 왼쪽 구간을left, 오른쪽 구간을 right 이라 할 때, 탐색 범위 [start, end] 와 합의 구간 [left, right] 의 관계는 다음과 같습니다.

  1. [left, right] 와 [start, end]가 전혀 겹치지 않는 경우

    탐색 범위 내에 구하는 범위가 존재하지 않습니다. 그렇다면 탐색 범위에 값들은 아무 의미없는 값이므로 0을 return 합니다.

  2. [start, end] 가 [left, right]에 속해 있는 경우

    탐색 범위 내에 값들이 전부 구하는 범위의 값들입니다. 하위 노드들을 탐색할 필요없이 이미 하위 노드들의 합을 저장하고 있는 tree[index] 를 반환합니다.

  3. [left, right] 가 [start, end]에 속해 있는 경우

  4. [left, right] 와 [start, end] 가 일부 겹치는 경우

    해당 경우에는 아직 결론을 내리기에 시기상조입니다. 재귀적으로 더 들어가서 어디까지의 값들이 필요한지에 대해 구해줍니다.

이를 코드로 구현하면 다음과 같습니다.

long long sum(int index, int start, int end, int left, int right){
    // 구간이 전혀 겹치지 않는 경우
    if (start > right || end < left)
        return 0;
    else if (start >= left && right <= end)
        return tree[index];
    else {
        int mid = (start+end) / 2;
        return sum(index*2+1, start, mid, left, right) + sum(index*2+2, mid+1, end, left, right);
    }
}

값 변경하기

마지막으로 배열의 값을 변경해보도록 하겠습니다. 이에 따라 세그먼트 트리의 값도 변경이 되야하죠.

void update(int changed_index, long long diff, int index, int start, int end){
    if (changed_index < start || changed_index > end)
        return;
    tree[index] += diff;
    
    if (start != end){
        int mid = (start+end) / 2;
        update(changed_index, diff, index*2+1, start, mid);
        update(changed_index, diff, index*2+2, mid+1, end);
    }
}

여기서 주의해야 할 점은 diff 는 바꿀 새로운 값이 아닙니다. diff = 새로 바꿀 값 - A[changed_index] (기존의 값) 으로서 새로 바꿀 값과 기존의 값의 차이입니다.

  • diff = 새로 바꿀 값 - A[changed_index] (기존의 값)

  • A[changed_index] = 새로 바꿀 값

  • 재귀적으로 양 쪽 자식노드로 나눠가며 start == end 가 될 때 까지, 즉 리프 노드가 될 때까지 탐색을 합니다. 탐색을 할 시에는 탐색 범위 안에 없다면 return 하고 탐색 범위 안에 있다면 변경된 노드의 증가값 diff 만큼 노드에 더해줍니다.

출처 - ssungkang.tistory.com/entry/Algorithm-세그먼트-트리Segment-Tree

 

크루스칼 알고리즘 - 13일차에 진행.

 

TCP (흐름제어/혼잡제어)


들어가기 전

  • TCP 통신이란?
    • 네트워크 통신에서 신뢰적인 연결방식
    • TCP는 기본적으로 unreliable network에서, reliable network를 보장할 수 있도록 하는 프로토콜
    • TCP는 network congestion avoidance algorithm을 사용
  • reliable network를 보장한다는 것은 4가지 문제점 존재
    • 손실 : packet이 손실될 수 있는 문제
    • 순서 바뀜 : packet의 순서가 바뀌는 문제
    • Congestion : 네트워크가 혼잡한 문제
    • Overload : receiver가 overload 되는 문제
  • 흐름제어/혼잡제어란?
    • 흐름제어 (endsystem 대 endsystem)
      • 송신측과 수신측의 데이터 처리 속도 차이를 해결하기 위한 기법
      • Flow Control은 receiver가 packet을 지나치게 많이 받지 않도록 조절하는 것
      • 기본 개념은 receiver가 sender에게 현재 자신의 상태를 feedback 한다는 점
    • 혼잡제어 : 송신측의 데이터 전달과 네트워크의 데이터 처리 속도 차이를 해결하기 위한 기법
  • 전송의 전체 과정
    • Application layer : sender application layer가 socket에 data를 씀.
    • Transport layer : data를 segment에 감싼다. 그리고 network layer에 넘겨줌.
    • 그러면 아랫단에서 어쨋든 receiving node로 전송이 됨. 이 때, sender의 send buffer에 data를 저장하고, receiver는 receive buffer에 data를 저장함.
    • application에서 준비가 되면 이 buffer에 있는 것을 읽기 시작함.
    • 따라서 flow control의 핵심은 이 receiver buffer가 넘치지 않게 하는 것임.
    • 따라서 receiver는 RWND(Receive WiNDow) : receive buffer의 남은 공간을 홍보함

1. 흐름제어 (Flow Control)

  • 수신측이 송신측보다 데이터 처리 속도가 빠르면 문제없지만, 송신측의 속도가 빠를 경우 문제가 생긴다.

  • 수신측에서 제한된 저장 용량을 초과한 이후에 도착하는 데이터는 손실 될 수 있으며, 만약 손실 된다면 불필요하게 응답과 데이터 전송이 송/수신 측 간에 빈번히 발생한다.

  • 이러한 위험을 줄이기 위해 송신 측의 데이터 전송량을 수신측에 따라 조절해야한다.

  • 해결방법

    • Stop and Wait : 매번 전송한 패킷에 대해 확인 응답을 받아야만 그 다음 패킷을 전송하는 방법

    • Sliding Window (Go Back N ARQ)

      • 수신측에서 설정한 윈도우 크기만큼 송신측에서 확인응답없이 세그먼트를 전송할 수 있게 하여 데이터 흐름을 동적으로 조절하는 제어기법

      • 목적 : 전송은 되었지만, acked를 받지 못한 byte의 숫자를 파악하기 위해 사용하는 protocol

        LastByteSent - LastByteAcked <= ReceivecWindowAdvertised

        (마지막에 보내진 바이트 - 마지막에 확인된 바이트 <= 남아있는 공간) ==

        (현재 공중에 떠있는 패킷 수 <= sliding window)

    • 동작방식 : 먼저 윈도우에 포함되는 모든 패킷을 전송하고, 그 패킷들의 전달이 확인되는대로 이 윈도우를 옆으로 옮김으로써 그 다음 패킷들을 전송

    • Window : TCP/IP를 사용하는 모든 호스트들은 송신하기 위한 것과 수신하기 위한 2개의 Window를 가지고 있다. 호스트들은 실제 데이터를 보내기 전에 '3 way handshaking'을 통해 수신 호스트의 receive window size에 자신의 send window size를 맞추게 된다.

    • 세부구조

      1. 송신 버퍼
        • -
        • 200 이전의 바이트는 이미 전송되었고, 확인응답을 받은 상태
        • 200 ~ 202 바이트는 전송되었으나 확인응답을 받지 못한 상태
        • 203 ~ 211 바이트는 아직 전송이 되지 않은 상태
      2. 수신 윈도우
      3. 송신 윈도우
        • 수신 윈도우보다 작거나 같은 크기로 송신 윈도우를 지정하게되면 흐름제어가 가능하다.
      4. 송신 윈도우 이동
        • Before : 203 ~ 204를 전송하면 수신측에서는 확인 응답 203을 보내고, 송신측은 이를 받아 after 상태와 같이 수신 윈도우를 203 ~ 209 범위로 이동
        • after : 205 ~ 209가 전송 가능한 상태
      5. Selected Repeat


2. 혼잡제어 (Congestion Control)

  • 송신측의 데이터는 지역망이나 인터넷으로 연결된 대형 네트워크를 통해 전달된다. 만약 한 라우터에 데이터가 몰릴 경우, 자신에게 온 데이터를 모두 처리할 수 없게 된다. 이런 경우 호스트들은 또 다시 재전송을 하게되고 결국 혼잡만 가중시켜 오버플로우나 데이터 손실을 발생시키게 된다. 따라서 이러한 네트워크의 혼잡을 피하기 위해 송신측에서 보내는 데이터의 전송속도를 강제로 줄이게 되는데, 이러한 작업을 혼잡제어라고 한다.
  • 또한 네트워크 내에 패킷의 수가 과도하게 증가하는 현상을 혼잡이라 하며, 혼잡 현상을 방지하거나 제거하는 기능을 혼잡제어라고 한다.
  • 흐름제어가 송신측과 수신측 사이의 전송속도를 다루는데 반해, 혼잡제어는 호스트와 라우터를 포함한 보다 넓은 관점에서 전송 문제를 다루게 된다.
  • 해결 방법
    • AIMD(Additive Increase / Multiplicative Decrease)
      • 처음에 패킷을 하나씩 보내고 이것이 문제없이 도착하면 window 크기(단위 시간 내에 보내는 패킷의 수)를 1씩 증가시켜가며 전송하는 방법
      • 패킷 전송에 실패하거나 일정 시간을 넘으면 패킷의 보내는 속도를 절반으로 줄인다.
      • 공평한 방식으로, 여러 호스트가 한 네트워크를 공유하고 있으면 나중에 진입하는 쪽이 처음에는 불리하지만, 시간이 흐르면 평형상태로 수렴하게 되는 특징이 있다.
      • 문제점은 초기에 네트워크의 높은 대역폭을 사용하지 못하여 오랜 시간이 걸리게 되고, 네트워크가 혼잡해지는 상황을 미리 감지하지 못한다. 즉, 네트워크가 혼잡해지고 나서야 대역폭을 줄이는 방식이다.
    • Slow Start (느린 시작)
      • AIMD 방식이 네트워크의 수용량 주변에서는 효율적으로 작동하지만, 처음에 전송 속도를 올리는데 시간이 오래 걸리는 단점이 존재했다.
      • Slow Start 방식은 AIMD와 마찬가지로 패킷을 하나씩 보내면서 시작하고, 패킷이 문제없이 도착하면 각각의 ACK 패킷마다 window size를 1씩 늘려준다. 즉, 한 주기가 지나면 window size가 2배로 된다.
      • 전송속도는 AIMD에 반해 지수 함수 꼴로 증가한다. 대신에 혼잡 현상이 발생하면 window size를 1로 떨어뜨리게 된다.
      • 처음에는 네트워크의 수용량을 예상할 수 있는 정보가 없지만, 한번 혼잡 현상이 발생하고 나면 네트워크의 수용량을 어느 정도 예상할 수 있다.
      • 그러므로 혼잡 현상이 발생하였던 window size의 절반까지는 이전처럼 지수 함수 꼴로 창 크기를 증가시키고 그 이후부터는 완만하게 1씩 증가시킨다.
    • Fast Retransmit (빠른 재전송)
      • 빠른 재전송은 TCP의 혼잡 조절에 추가된 정책이다.
      • 패킷을 받는 쪽에서 먼저 도착해야할 패킷이 도착하지 않고 다음 패킷이 도착한 경우에도 ACK 패킷을 보내게 된다.
      • 단, 순서대로 잘 도착한 마지막 패킷의 다음 패킷의 순번을 ACK 패킷에 실어서 보내게 되므로, 중간에 하나가 손실되게 되면 송신 측에서는 순번이 중복된 ACK 패킷을 받게 된다. 이것을 감지하는 순간 문제가 되는 순번의 패킷을 재전송 해줄 수 있다.
      • 중복된 순번의 패킷을 3개 받으면 재전송을 하게 된다. 약간 혼잡한 상황이 일어난 것이므로 혼잡을 감지하고 window size를 줄이게 된다.
    • Fast Recovery (빠른 회복)
      • 혼잡한 상태가 되면 window size를 1로 줄이지 않고 반으로 줄이고 선형증가시키는 방법이다. 이 정책까지 적용하면 혼잡 상황을 한번 겪고 나서부터는 순수한 AIMD 방식으로 동작하게 된다.

 

메인 메모리(main memory)

메인 메모리는 CPU가 직접 접근할 수 있는 접근 장치

프로세스가 실행되려면 프로그램이 메모리에 올라와야 함

주소가 할당된 일련의 바이트들로 구성되어 있음

 

CPU는 레지스터가 지시하는대로 메모리에 접근하여 다음에 수행할 명령어를 가져옴

명령어 수행 시 메모리에 필요한 데이터가 없으면 해당 데이터를 우선 가져와야 함

이 역할을 하는 것이 바로 MMU

 

메모리 관리장치(MMU)는 논리 주소를 물리주소로 변환해줌

뿐만 아니라 메모리 보호나 캐시 관리 등 CPU가 메모리에 접근하는 것을 총 관리해주는 하드웨어임

 

메모리의 공간이 한정적이기 때문에, 사용자에게 더 많은 메모리를 제공하기 위해 '가상 주소'라는 개념이 등장 (가상 주소는 프로그램 상에서 사용자가 보는 주소 공간이라고 보면 됨)

이 가상 주소에서 실제 데이터가 담겨 있는 곳에 접근하기 위해선 빠른 주소 변환이 필요한데, 이를 MMU가 도와주는 것

 

또한 메인 메모리의 직접 접근은 비효율적이므로, CPU와 메인 메모리 속도를 맞추기 위해 캐시가 존재함


MMU의 메모리 보호

프로세스는 독립적인 메모리 공간을 가져야 되고, 자신의 공간만 접근해야 함

따라서 한 프로세스에게 합법적인 주소 영역을 설정하고, 잘못된 접근이 오면 trap을 발생시키며 보호함

base와 limit 레지스터를 활용한 메모리 보호 기법

base 레지스터는 메모리상의 프로세스 시작주소를 물리 주소로 저장 limit 레지스터는 프로세스의 사이즈를 저장

이로써 프로세스의 접근 가능한 합법적인 메모리 영역(x)은

base <= x < base+limit

이 영역 밖에서 접근을 요구하면 trap을 발생시키는 것

 

안전성을 위해 base와 limit 레지스터는 커널 모드에서만 수정 가능하도록 설계 (사용자 모드에서는 직접 변경할 수 없도록)



메모리 과할당(over allocating)

실제 메모리의 사이즈보다 더 큰 사이즈의 메모리를 프로세스에 할당한 상황

 

페이지 기법과 같은 메모리 관리 기법은 사용자가 눈치 채지 못하도록 눈속임을 통해 메모리를 할당해줌 (가상 메모리를 이용해서)

 

과할당 상황에 대해서 사용자를 속인 것을 들킬만한 상황이 존재함

  1. 프로세스 실행 도중 페이지 폴트 발생
  2. 페이지 폴트를 발생시킨 페이지 위치를 디스크에서 찾음
  3. 메모리의 빈 프레임에 페이지를 올려야 하는데, 모든 메모리가 사용중이라 빈 프레임이 없음

 

이러한 과할당을 해결하기 위해선, 빈 프레임을 확보할 수 있어야 함

  1. 메모리에 올라와 있는 한 프로세스를 종료시켜 빈 프레임을 얻음

  2. 프로세스 하나를 swap out하고, 이 공간을 빈 프레임으로 활용

 

swapping 기법을 통해 공간을 바꿔치기하는 2번 방법과는 달리 1번은 사용자에게 페이징 시스템을 들킬 가능성이 매우 높아서 하면 안됨

(페이징 기법은 사용자 모르게 시스템 능률을 높이기 위해 선택한 일이므로 들키지 않게 처리해야한다)

 

따라서, 2번과 같은 해결책을 통해 페이지 교체가 이루어져야 함



페이지 교체

메모리 과할당이 발생했을 때, 프로세스 하나를 swap out해서 빈 프레임을 확보하는 것

 

  1. 프로세스 실행 도중 페이지 부재 발생

  2. 페이지 폴트를 발생시킨 페이지 위치를 디스크에서 찾음

  3. 메모리에 빈 프레임이 있는지 확인

    빈 프레임이 있으면 해당 프레임을 사용

    빈 프레임이 없으면, victim 프레임을 선정해 디스크에 기록하고 페이지 테이블을 업데이트함

  4. 빈 프레임에 페이지 폴트가 발생한 페이지를 올리고, 페이지 테이블 업데이트

 

페이지 교체가 이루어지면 아무일이 없던것 처럼 프로세스를 계속 수행시켜주면서 사용자가 알지 못하도록 해야 함

이때, 아무일도 일어나지 않은 것처럼 하려면, 페이지 교체 당시 오버헤드를 최대한 줄여야 함


오버헤드를 감소시키는 해결법

이처럼 빈 프레임이 없는 상황에서 victim 프레임을 비울 때와 원하는 페이지를 프레임으로 올릴 때 두 번의 디스크 접근이 이루어짐

페이지 교체가 많이 이루어지면, 이처럼 입출력 연산이 많이 발생하게 되면서 오버헤드 문제가 발생함


방법1

변경비트를 모든 페이지마다 둬서, victim 페이지가 정해지면 해당 페이지의 비트를 확인

해당 비트가 set 상태면? → 해당 페이지 내용이 디스크 상의 페이지 내용과 달라졌다는 뜻 (즉, 페이지가 메모리 올라온 이후 한번이라도 수정이 일어났던 것. 따라서 이건 디스크에 기록해야함)

bit가 clear 상태라면? → 디스크 상의 페이지 내용과 메모리 상의 페이지가 정확히 일치하는 상황 (즉, 디스크와 내용이 같아서 기록할 필요가 없음)

비트를 활용해 디스크에 기록하는 횟수를 줄이면서 오버헤드에 대한 수를 최대 절반으로 감소시키는 방법임


방법2

페이지 교체 알고리즘을 상황에 따라 잘 선택해야 함

현재 상황에서 페이지 폴트를 발생할 확률을 최대한 줄여줄 수 있는 교체 알고리즘을 사용

FIFO

OPT

LRU




캐시 메모리

주기억장치에 저장된 내용의 일부를 임시로 저장해두는 기억장치

CPU와 주기억장치의 속도 차이로 성능 저하를 방지하기 위한 방법

CPU가 이미 봤던걸 다시 재접근할 때, 메모리 참조 및 인출 과정에 대한 비용을 줄이기 위해 캐시에 저장해둔 데이터를 활용한다

캐시는 플리플롭 소자로 구성되어 SRAM으로 되어있어서 DRAM보다 빠른 장점을 지님


CPU와 기억장치의 상호작용

CPU에서 주소를 전달 → 캐시 기억장치에 명령이 존재하는지 확인

(존재) Hit

해당 명령어를 CPU로 전송 → 완료

(비존재) Miss

명령어를 갖고 주기억장치로 접근 → 해당 명령어를 가진 데이터 인출 → 해당 명령어 데이터를 캐시에 저장 → 해당 명령어를 CPU로 전송 → 완료

 

이처럼 캐시를 잘 활용한다면 비용을 많이 줄일 수 있음

따라서 CPU가 어떤 데이터를 원할지 어느정도 예측할 수 있어야 함

(캐시에 많이 활용되는 쓸모 있는 정보가 들어있어야 성능이 높아짐)

 

적중률을 극대화시키기 위해 사용되는 것이 바로 지역성의 원리

지역성

기억 장치 내의 정보를 균일하게 액세스 하는 것이 아니라 한 순간에 특정부분을 집중적으로 참조하는 특성

 

지역성의 종류는 시간과 공간으로 나누어짐

시간 지역성 : 최근에 참조된 주소의 내용은 곧 다음에도 참조되는 특성

공간 지역성 : 실제 프로그램이 참조된 주소와 인접한 주소의 내용이 다시 참조되는 특성



캐싱 라인

빈번하게 사용되는 데이터들을 캐시에 저장했더라도, 내가 필요한 데이터를 캐시에서 찾을 때 모든 데이터를 순회하는 것은 시간 낭비다.

즉, 캐시에 목적 데이터가 저장되어있을 때 바로 접근하여 출력할 수 있어야 캐시 활용이 의미있어짐

따라서 캐시에 데이터를 저장할 시, 자료구조를 활용해 묶어서 저장하는데 이를 캐싱 라인이라고 부른다.

즉, 캐시에 저장하는 데이터에 데이터의 메모리 주소를 함께 저장하면서 빠르게 원하는 정보를 찾을 수 있음 (set이나 map 등을 활용)

 

 

SQL과 NOSQL의 차이

 

웹 앱을 개발할 때, 데이터베이스를 선택할 때 고민하게 된다.


MySQL과 같은 SQL을 사용할까? 아니면 MongoDB와 같은 NoSQL을 사용할까?

보통 Spring에서 개발할 때는 MySQL을, Node.js에서는 MongoDB를 주로 사용했을 것이다.

하지만 그냥 단순히 프레임워크에 따라 결정하는 것이 아니다. 프로젝트를 진행하기에 앞서 적합한 데이터베이스를 택해야 한다. 차이점을 알아보자


SQL (관계형 DB)


SQL을 사용하면 RDBMS에서 데이터를 저장, 수정, 삭제 및 검색 할 수 있음

관계형 데이터베이스에는 핵심적인 두 가지 특징이 있다.

  • 데이터는 정해진 데이터 스키마에 따라 테이블에 저장된다.
  • 데이터는 관계를 통해 여러 테이블에 분산된다.

 

데이터는 테이블에 레코드로 저장되는데, 각 테이블마다 명확하게 정의된 구조가 있다. 해당 구조는 필드의 이름과 데이터 유형으로 정의된다.

따라서 스키마를 준수하지 않은 레코드는 테이블에 추가할 수 없다. 즉, 스키마를 수정하지 않는 이상은 정해진 구조에 맞는 레코드만 추가가 가능한 것이 관계형 데이터베이스의 특징 중 하나다.

 

또한, 데이터의 중복을 피하기 위해 '관계'를 이용한다.

하나의 테이블에서 중복 없이 하나의 데이터만을 관리하기 때문에 다른 테이블에서 부정확한 데이터를 다룰 위험이 없어지는 장점이 있다.



NoSQL (비관계형 DB)


말그대로 관계형 DB의 반대다.

스키마도 없고, 관계도 없다!

 

NoSQL에서는 레코드를 문서(documents)라고 부른다.

여기서 SQL과 핵심적인 차이가 있는데, SQL은 정해진 스키마를 따르지 않으면 데이터 추가가 불가능했다. 하지만 NoSQL에서는 다른 구조의 데이터를 같은 컬렉션에 추가가 가능하다.

 

문서(documents)는 Json과 비슷한 형태로 가지고 있다. 관계형 데이터베이스처럼 여러 테이블에 나누어담지 않고, 관련 데이터를 동일한 '컬렉션'에 넣는다.

따라서 위 사진에 SQL에서 진행한 Orders, Users, Products 테이블로 나눈 것을 NoSQL에서는 Orders에 한꺼번에 포함해서 저장하게 된다.

따라서 여러 테이블에 조인할 필요없이 이미 필요한 모든 것을 갖춘 문서를 작성하는 것이 NoSQL이다. (NoSQL에는 조인이라는 개념이 존재하지 않음)

 

그러면 조인하고 싶을 때 NoSQL은 어떻게 할까?

컬렉션을 통해 데이터를 복제하여 각 컬렉션 일부분에 속하는 데이터를 정확하게 산출하도록 한다.

하지만 이러면 데이터가 중복되어 서로 영향을 줄 위험이 있다. 따라서 조인을 잘 사용하지 않고 자주 변경되지 않는 데이터일 때 NoSQL을 쓰면 상당히 효율적이다.



확장 개념

두 데이터베이스를 비교할 때 중요한 Scaling 개념도 존재한다.

데이터베이스 서버의 확장성은 '수직적' 확장과 '수평적' 확장으로 나누어진다.

  • 수직적 확장 : 단순히 데이터베이스 서버의 성능을 향상시키는 것 (ex. CPU 업그레이드)
  • 수평적 확장 : 더 많은 서버가 추가되고 데이터베이스가 전체적으로 분산됨을 의미 (하나의 데이터베이스에서 작동하지만 여러 호스트에서 작동)

 

데이터 저장 방식으로 인해 SQL 데이터베이스는 일반적으로 수직적 확장만 지원함

수평적 확장은 NoSQL 데이터베이스에서만 가능



그럼 둘 중에 뭘 선택?

정답은 없다. 둘다 훌륭한 솔루션이고 어떤 데이터를 다루느냐에 따라 선택을 고려해야한다.


SQL 장점

  • 명확하게 정의된 스키마, 데이터 무결성 보장
  • 관계는 각 데이터를 중복없이 한번만 저장

SQL 단점

  • 덜 유연함. 데이터 스키마를 사전에 계획하고 알려야 함. (나중에 수정하기 힘듬)
  • 관계를 맺고 있어서 조인문이 많은 복잡한 쿼리가 만들어질 수 있음
  • 대체로 수직적 확장만 가능함


NoSQL 장점

  • 스키마가 없어서 유연함. 언제든지 저장된 데이터를 조정하고 새로운 필드 추가 가능
  • 데이터는 애플리케이션이 필요로 하는 형식으로 저장됨. 데이터 읽어오는 속도 빨라짐
  • 수직 및 수평 확장이 가능해서 애플리케이션이 발생시키는 모든 읽기/쓰기 요청 처리 가능

NoSQL 단점

  • 유연성으로 인해 데이터 구조 결정을 미루게 될 수 있음
  • 데이터 중복을 계속 업데이트 해야 함
  • 데이터가 여러 컬렉션에 중복되어 있기 때문에 수정 시 모든 컬렉션에서 수행해야 함 (SQL에서는 중복 데이터가 없으므로 한번만 수행이 가능)



SQL 데이터베이스 사용이 더 좋을 때

  • 관계를 맺고 있는 데이터가 자주 변경되는 애플리케이션의 경우

    NoSQL에서는 여러 컬렉션을 모두 수정해야 하기 때문에 비효율적

  • 변경될 여지가 없고, 명확한 스키마가 사용자와 데이터에게 중요한 경우


NoSQL 데이터베이스 사용이 더 좋을 때

  • 정확한 데이터 구조를 알 수 없거나 변경/확장 될 수 있는 경우
  • 읽기를 자주 하지만, 데이터 변경은 자주 없는 경우
  • 데이터베이스를 수평으로 확장해야 하는 경우 (막대한 양의 데이터를 다뤄야 하는 경우)



하나의 제시 방법이지 완전한 정답이 정해져 있는 것은 아니다.

SQL을 선택해서 복잡한 JOIN문을 만들지 않도록 설계하여 단점을 없앨 수도 있고

NoSQL을 선택해서 중복 데이터를 줄이는 방법으로 설계해서 단점을 없앨 수도 있다.

 

Java의 String에 관해서

Java에서 String은 굉장히 자주 사용되며, 두 가지 생성 방식이 있다.

  1. new 연산자를 이용한 방식
  2. 리터럴을 이용한 방식

이 두 가지 방식에는 큰 차이점이 존재한다.

new를 통해 String 객체를 생성하면 Heap 영역에 존재하게 된다.

리터럴을 이용할 경우, String Constant Pool이라는 영역에 존재하게 된다.

public class StringMemory{ public static void main(String[] args){ String literal = "loper"; String object = new String("loper"); literal == object; // false literal.equals(object); // true } }

  • == 연산의 결과는 false이다. == 연산자는 객체의 주소값을 비교하기 때문에 일반 객체처럼 Heap 영역에 생성된 String 객체와 리터럴을 이용해 String Constant Pool에 저장된 String 객체의 주소값은 다를 수 밖에 없다.
  • equals() 메소드의 수행 결과는 true이다. 이는 문자열 내용을 비교하기 때문에 같은 문자열에 대해서 true를 반환하는 것이 맞다.

왜 이런 결과가 나올까?

이를 위해서는 동작 방식에 대한 이해가 필요하다. String을 리터럴로 선언할 경우, 내부적으로 String의 intern()이라는 메소드가 호출되게 된다.

  • intern() : 주어진 문자열이 String Constant Pool에 존재하는지 검색하고 있다면 그 주소값을 반환하고 없다면 String Constant Pool에 넣고 새로운 주소값을 반환하게 된다.

public class StringMemoryInternTest{ public static void main(Strig[] args){ String literal = "loper"; String object = new String("loper"); String intern = object.intern(); // 명시적으로 호출. literal == object; // false literal.equals(object); // true literal == intern; // true literal.equals(intern); // true } }

기존에 new를 통해 생성된 String 객체와 리터럴로 생성된 String 객체를 == 연산하였을 경우, false를 반환했지만 new를 통해 생성된 String 객체의 intern() 메소드를 호출하여 새로운 String 객체인 intern에 대입할 경우, 리터럴로 생성된 String 객체와 == 연산시 true를 반환하게 된다.

위에서 설명했듯이 리터럴로 "loper"라는 문자열이 String Constant Pool에 저장되었고, intern() 메소드를 호출하면서 String Constant Pool에 "loper"라는 문자열을 검색하게 되고 이미 저장된 "loper" 문자열을 발견할테니 동일한 주소값을 반환하게 되므로 true가 성립되는 것이다.

String Constant Pool의 위치 변경

Java 6까지 String Constant Pool의 위치는 Perm 영역이었다. Perm 영역에 위치했던게 Java 7에서 Heap 영역으로 변경되었다. 그 이유는 OOM(Out Of Memory) 때문이다.

Perm 영역은 고정된 사이즈이며 Runtime에 사이즈가 확장되지 않는다. Perm 영역의 사이즈를 늘릴수는 있지만 어쨌거나 Runtime에 사이즈가 변경되는 것은 아니다.

그래서 Java 6까지는 String의 intern() 메소드를 호출하는 것은 OOM을 발생시킬 수 있고 그 부분을 컨트롤할 수 없었기 때문에 거의 사용하지 않는 것이 맞다.

그래서 Oracle의 엔지니어들이 Java 7에서 Perm 영역이 아닌 Heap 영역으로 String Constant Pool의 위치를 변경했다. Heap 영역으로 변경함으로써 얻는 이점은 무엇일까?

-> 바로 String Constant Pool의 모든 문자열도 GC의 대상이 될 수 있다는 점이다.

String Constant Pool의 사이즈 또한 지정할 수 있는데, -xx:StringTableSize 옵션으로 설정이 가능하다. 여기에는 1,000,000과 같은 숫자가 아닌 1,000,003과 같은 소수를 사용해야 한다. 이는 hashCode 성능과 관련된 부분이며 아티클에 자세한 내용이 나와있다.

++ 2020.09.19 추가 해당 블로그의 내용이 이해가 잘되어서 첨부하며, 추후에 추가하도록 하겠다.

 

 

1. String 특징

  • new 연산을 통해 생성된 인스턴스의 메모리 공간은 변하지 않음 (Immutable)
  • Garbage Collector로 제거되어야 함.
  • 문자열 연산시 새로 객체를 만드는 Overhead 발생
  • 객체가 불변하므로, Multithread에서 동기화를 신경 쓸 필요가 없음. (조회 연산에 매우 큰 장점)

String 클래스 : 문자열 연산이 적고, 조회가 많은 멀티쓰레드 환경에서 좋음


2. StringBuffer, StringBuilder 특징

  • 공통점
    • new 연산으로 클래스를 한 번만 만듬 (Mutable)
    • 문자열 연산시 새로 객체를 만들지 않고, 크기를 변경시킴
    • StringBuffer와 StringBuilder 클래스의 메서드가 동일함.
  • 차이점
    • StringBuffer는 Thread-Safe함 / StringBuilder는 Thread-safe하지 않음 (불가능)

 

StringBuffer 클래스 : 문자열 연산이 많은 Multi-Thread 환경

StringBuilder 클래스 : 문자열 연산이 많은 Single-Thread 또는 Thread 신경 안쓰는 환경

'CS 공부 > CS Study 원본' 카테고리의 다른 글

CS 18일차  (0) 2021.02.22
CS 17일차  (0) 2021.02.19
CS 15일차  (0) 2021.02.08
CS 13일차  (0) 2021.02.01
CS 12일차  (0) 2021.01.29