접근

이러한 최단거리 문제를 풀 때, 현재 그래프의 데이터를 저장하는 데에는 두 가지 방식이 있다. 간단하게는 이차원 배열을 선언해서 연결된 곳에는 그에 해당하는 거리를, 갈 수 없는 노드라면 -1 혹은 INF값을 저장해놓는 방식이다. 다만 이 경우에는 NxN 으로 모든 노드의 연결 여부를 저장하므로, 간선의 개수가 적은 편이라면 메모리를 낭비하게 된다. 즉, N값이 커지면 메모리 초과가 날 수도 있다는 얘기다. 

 

두 번째로는 이차원 ArrayList를 통해 서로 연결된 노드의 정보만을 저장하는 방식이다. 첫 번째 방식보다 코드 구현 측면에서는 더 복잡하지만, 노드 개수에 비해 간선의 개수가 적다면 이 방식이 더 효율적이다. 나는 두번째 방식으로 풀었다.

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class BJ18352 {
    static class City {
        int num, dist;

        City(int num, int dist){
            this.num = num;
            this.dist = dist;
        }
    }

    static int INF = -1;
    static ArrayList<ArrayList<Integer>> graph;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = null;
        ArrayList<Integer> answer = new ArrayList<>();

        // 입력값 저장
        st = new StringTokenizer(br.readLine());
        int n = Integer.parseInt(st.nextToken());
        int m = Integer.parseInt(st.nextToken());
        int k = Integer.parseInt(st.nextToken());
        int x = Integer.parseInt(st.nextToken());

        graph = new ArrayList<>();
        int[] dist = new int[n+1];

        for (int i = 0; i <= n; i++) {
            graph.add(new ArrayList<>());
            dist[i] = INF;
        }

        for (int i = 0; i < m; i++) {
            st = new StringTokenizer(br.readLine());
            int a = Integer.parseInt(st.nextToken());
            int b = Integer.parseInt(st.nextToken());
            graph.get(a).add(b);
        }

        // 다익스트라
        dijkstra(dist, x);

        // 거리가 같은 도시의 개수 세기
        for (int i = 1; i <= n; i++) {
            if(dist[i] == k)
                answer.add(i);
        }

        if(answer.size() == 0){
            System.out.println(-1);
        }
        else{
            Collections.sort(answer);
            for (int i = 0, s = answer.size(); i < s; i++) {
                System.out.println(answer.get(i));
            }
        }
    }

    static void dijkstra(int[] dist, int x){
        Queue<City> q = new LinkedList<>();
        
        // 시작 지점 처리
        q.add(new City(x, 0));
        dist[x] = 0;

        while(!q.isEmpty()){
            City cur = q.poll();

            for(int node : graph.get(cur.num)){
                if(dist[node] == INF || dist[node] > cur.dist+1) {
                    dist[node] = cur.dist+1;
                    q.add(new City(node, cur.dist+1));
                }
            }
        }
    }
}
<관련 포스팅>
[네트워크 기초] 허브, 스위치, 라우터 차이
[네트워크 계층 모델] TCP/IP 4계층

[네트워크 계층 모델] OSI 7계층
[네트워크] TCP/UDP 차이(3, 4-way handshaking)

네트워크 기기의 처리 범위

  • OSI 7계층별로 처리 범위를 나눌 수 있음
  • 상위 계층을 처리하는 기기는 하위 계층을 처리할 수 있지만 그 반대는 불가능

 

계층별 기기의 종류

  • 물리 계층: 허브, NIC, 리피터, AP
  • 데이터 링크 계층: L2 스위치, 브리지
  • 인터넷 계층: 라우터, L3 스위치
  • 전송 계층: L4 스위치
  • 애플리케이션 계층: L7 스위치

 


1계층(물리 계층) 기기

NIC(Network Interface Card)

  • 컴퓨터와 네트워크 사이의 중개자 역할
  • 이더넷 케이블을 사용하여 네트워크에 연결하기 위한 컴퓨터 확장 카드
  • 이더넷 표준의 인기와 낮은 비용 때문에 대부분의 마더보드에 네트워크 인터페이스가 내장됨
  • 각 카드에는 고유의 식별번호인 MAC 주소가 있음
  • 전송시, CPU가 패킷 생성 → NIC가 전송 시작 → 종료 시, 인터럽트로 CPU에 통보
  • 수신시, CPU 메모리에 버퍼공간 할당 → NIC가 버퍼에 데이터 저장 → 종료 시, 인터럽트로 CPU에 통보

 

리피터

  • 단지 약해진 전기 신호를 증폭하여 전달하는 역할을 수행
  • 현재는 광케이블이 보급됨에 따라 잘 쓰이지 않음

 

더미 허브(L1 스위치)

  • 리피터 역할을 하며, 기존의 리피터와 다르게 여러 장비를 연결할 수 있음
  • CSMA/CD 방식을 적용하고 있기 때문에 여러 장비에서 동시에 데이터를 전송하지 못함
  • 하나의 허브에 연결된 모든 장비는 같은 Collision(충돌) 도메인에 속함

 

AP(Access Point)

  • 패킷을 복사하는 기기
  • AP에 유선 LAN을 연결한 후 다른 장치에서 무선 LAN 기술(와이파이 등)을 사용하여 무선 네트워크 연결을 할 수 있음
  • 공유기도 AP의 한 종류

 


2계층(데이터 링크 계층) 기기

브리지

  • 두 개의 근거리 통신망(LAN)을 상호 접속할 수 있도록 하는 연결 장치 → 통신망 범위를 확장
  • 포트와 포트 사이의 다리 역할을 하며, MAC 주소를 테이블로 관리
  • 단순히 전기적 신호만을 증폭시키는 것이 아니라 Frame의 전송 포트를 결정하여 다시 만들어 전송

 

L2 스위치

  • 가장 기본적인 스위치(계층 지정 없이 부를 경우 이에 해당)
  • 장치들의 MAC 주소를 테이블을 통해 관리
  • 단순히 패킷의 MAC 주소를 읽어 스위칭하는 역할
  • IP주소를 이해하지 못해 IP주소를 기반으로 라우팅은 불가능

 

브리지와 스위치의 차이

  • 스위치는 HW, 브리지는 SW 방식으로 처리함
  • 스위치의 속도가 더 빠르고, 제공되는 포트 수가 많으며 포트 별로 속도를 지정할 수도 있음
  • 스위치가 브리지의 상위 호환 장비로, 브리지는 현재 사용되지 않음

 

수행 기능

  • Learning: 호스트에서 요청이 오면 해당 MAC 주소를 테이블에 저장함
  • Flooding: 목적지 정보가 테이블에 없으면 모든 포트로 데이터를 전송함(Broadcasting)
  • Forwarding: 테이블의 MAC 주소를 참조하여 해당 포트로 데이터를 전송함(Unicasting)
  • Filtering: 출발지와 목적지가 같은 세그먼트에 있는 경우에는 다른 세그먼트로의 통로를 차단
    → 허브와 달리 Collision 도메인을 나눌 수 있음
  • Aging: 특정 시간이 지나면 테이블에서 오래된 MAC 주소를 삭제함

 


3계층(네트워크 계층) 기기

라우터

  • 네트워크간 데이터 전송을 위해 패킷 소모를 최소화하고 경로를 최적화하여 패킷을 포워딩
  • L3 스위치에 비해 가격이 비싸고 초기 설정이 복잡하다는 단점이 있음

 

L3 스위치

  • L2 스위치에 라우팅 기능이 추가된 장비로, IP 정보를 보고 스위칭
  • 기능적으로는 라우터와 차이가 거의 없으며 L3 스위치를 라우터라 해도 무방함
  • 라우터는 SW 방식이고 L3 스위치는 HW 방식이기 때문에 스위치가 훨씬 빠르고 저렴
  • 제품에 따라 지원하는 기능이 다르기 때문에 라우터와 스위치는 용도에 맞게 사용하면 됨
구분 L2 스위치 L3 스위치
참조 테이블 MAC 주소 테이블 라우팅 테이블
참조 PDU 이더넷 프레임 IP 패킷
참조 주소 MAC 주소 IP 주소

 


4계층(전송 계층) 기기

L4 스위치

  • TCP/UDP 등의 헤더를 보고 FTP, HTTP 등 프로토콜을 확인하여 스위칭의 우선 순위를 부여
  • IP주소와 PORT 기반으로 스위칭
  • 로드 밸런싱(많은 양의 트래픽을 여러 서버로 분산) 기능을 제공

 

로드밸런서를 이용한 서버 이중화

 

  • 여러 대의 서버를 마치 하나의 서버처럼 동작시키는 것
  • 로드밸런서의 대표적인 기능으로 서버 이중화를 들 수 있음
  • 성능을 쉽게 확장하고, 장애 발생 시에도 타 서버로 운영이 가능하게 함으로써 신뢰성을 향상

 


7계층(응용 계층) 기기

L7 스위치

  • 로드 밸런서라고도 하며, 시스템이 처리할 수 있는 트래픽 증가를 목표로 함
  • URL, 서버, 캐시, 쿠키뿐 아니라 IP주소, TCP/UDP 포트 정보의 패킷 내용까지 참조하여 세밀하게 트래픽을 분산함
  • 바이러스, 불필요한 외부 데이터 등을 걸러내는 필터링 기능
  • 응용 프로그램 수준의 트래픽 모니터링도 가능
  • 정기적인 헬스 체크(health check)를 통해 장애가 발생한 서버를 감지하고 분산 대상에서 제외

 

L4 스위치와 L7 스위치의 차이

  • L4 스위치와 기능과 역할은 동일하나, 추가적으로 페이로드를 분석하여 패킷을 처리
  • 페이로드를 분석함으로써 네트워크 공격에 대해 방어가 가능하며, 특정 바이러스 감염 패킷을 필터링할 수도 있음
  • L7 스위치는 네트워크 보안 강화의 장점이 있지만, 가격이 비싸다는 단점
  • L4 스위치는 스트리밍 관련 서비스에는 사용할 수 없으며 메시지를 기반으로 인식하지 못하고 IP와 포트를 기반으로(특히 포트) 트래픽을 분산함
  • 반면 L7 로드밸런서는 IP, 포트 이외에도 URL, HTTP 헤더, 쿠키 등을 기반으로 트래픽을 분산함

 

헬스 체크(health check)

  • 전송 주기와 재전송 횟수 등을 설정한 이후 반복적으로 서버에 요청을 보내는 것
  • L4, L7 스위치 모두 헬스 체크를 통해 정상적인/비정상적인 서버를 판별
  • 서버에 부하가 되지 않을 만큼 요청 횟수가 적절해야 함
  • TCP, HTTP 등 다양한 방법으로 요청을 보내며 이 요청이 정상적으로 이루어졌다면 정상적인 서버로 판별함
  • 예를 들어 TCP 요청을 보냈는데 3-way handshake가 정상적으로 일어나지 않았다면 정상이 아닌 것

 


References

접근

결국 N-K 길이의 숫자를 만드는 것은 동일하므로, 같은 길이의 숫자 중에서 가장 크기 위해서는 결국 맨 왼쪽에 오는 숫자가 가장 커야 한다. 따라서 N자리 수의 가장 왼쪽에서부터 오른쪽 수들과 하나씩 비교하며 작을 경우 지우면 된다. 스택에 저장하며 가장 마지막 숫자와 하나씩 비교하며 계산한다. 단, 모든 연산이 끝난 이후 1111 혹은 4321과 같이 k개를 다 지우지 못했을 경우에는 그만큼 뒤쪽에서 지워준다. 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class BJ2812 {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        StringBuilder sb = new StringBuilder();

        // 입력값 저장
        int n = Integer.parseInt(st.nextToken());
        int k = Integer.parseInt(st.nextToken());
        char[] input = br.readLine().toCharArray();

        // 스택에 저장하며 하나씩 비교
        Stack<Character> stk = new Stack<>();

        for (int i = 0; i < n; i++) {
            char cur = input[i];

            while (!stk.isEmpty() && stk.peek() < cur) {
                if(k == 0) break;
                k--;
                stk.pop();
            }

            stk.add(cur);
        }

        // 뒷자리에서 지워야하는 경우
        while(k > 0){
            stk.pop();
            k--;
        }

        // 스택에 저장된 문자를 출력하기 위해 StringBuilder에 저장
        while (!stk.isEmpty())
            sb.append(stk.pop());

        System.out.println(sb.reverse()); // 원하는 순서와 반대로 저장되어 있으므로
    }
}

접근

여러 장의 카드 묶음이 있을 때, 두 묶음을 합치기 위해서는 항상 카드 장수의 합만큼 비교해야 한다. 그렇다면 총 비교 횟수의 최소값을 구하기 위해서는 항상 장수가 가장 적은 두 묶음끼리 합치면 된다. 따라서 초기에만 입력값을 정렬하는 것이 아니라, 계속해서 우선순위 큐에 넣음으로써 해결했다. 

 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class BJ1715 {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());
        PriorityQueue<Integer> pq = new PriorityQueue<>();

        for (int i = 0; i < n; i++)
            pq.add(Integer.parseInt(br.readLine()));

        int answer = 0;
        while(pq.size() > 1){
            int sum = pq.poll() + pq.poll();
            answer += sum;
            pq.add(sum);
        }

        System.out.println(answer);
    }
}

접근

이 문제 역시 1946번과 같이 제한 시간 내에 풀기 위해서는 O(N^2)이 되면 안된다. 강의실의 개수를 최소로 사용하기 위해서는 최대한 많은 수업이 연속해서 일어나야 한다. 즉, 최대한 (앞 수업의 종료 시간) <= (뒷 수업의 시작 시간)이 되도록 만들어야 한다.

우선 수업 시간의 시작 시간이 가장 빠른 순서대로, 만약 시작 시간이 같다면 종료 시간이 빠른 순서대로 정렬한다. 그 뒤에 앞선 수업들의 종료시간 중 가장 빨리 끝나는 값 현재 수업의 시작 시간을 비교하면 된다. (우선순위 큐를 사용한다는 의미) 모든 연산이 종료되고 나면, pq의 사이즈값이 곧 사용하는 강의실의 최소 개수가 된다. 

 

(1, 2), (1, 7), (2, 4), (5, 6) 총 4개의 강의가 있다고 해보자. 결론부터 말하면 (1, 2)~(2, 4)~(5, 6) / (1, 7) 으로 총 2개의 강의실을 사용하게 된다. 

 

먼저 첫 강의는 무조건 빈 강의실에 배정될 수 있으므로, 해당 종료 시간이 그대로 pq에 들어간다. 

(1, 2), (1, 7), (2, 4), (5, 6) 

endTime(pq): 2

 

두 번째 강의의 경우, 앞선 강의의 종료시간보다 시작시간이 빠르기 때문에 같은 강의실을 사용할 수 없다. 따라서 해당 강의의 종료 시간을 pq에 넣어준다.

(1, 2), (1, 7), (2, 4), (5, 6) 

endTime(pq): 2, 7

 

세 번째 강의의 경우, pq에서 꺼낸 값이 2로 시작 시간과 동일하므로 같은 강의실을 사용할 수 있다. 2값을 빼고 이어서 진행되는 수업의 종료 시간을 넣는다. 

(1, 2), (1, 7), (2, 4), (5, 6) 

endTime(pq): 4, 7

 

마지막 강의의 경우, pq에서 꺼낸 값이 4로 시작 시간인 5보다 작기 때문에 같은 강의실을 사용할 수 있다. 4를  빼고 6을 넣어준다.

(1, 2), (1, 7), (2, 4), (5, 6) 

endTime(pq): 6, 7

 

모든 계산을 마치고 나면, 처음에 예시와 함께 보았던 강의실의 마지막 수업 종료 시간과 pq에 들어있는 값이 일치하게 된다. 즉, pq의 사이즈가 곧 필요한 강의실의 개수가 되는 것을 확인했다. 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class BJ11000 {
    static class Table implements Comparable<Table>{
        int s, t;

        Table(int s, int t){
            this.s = s;
            this.t = t;
        }

        // 시작 시간이 빠른 순서대로, 같다면 빨리 끝나는 순으로 정렬
        @Override
        public int compareTo(Table o) {
            if(this.s == o.s)
                return this.t - o.t;
            else return this.s - o.s;
        }
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = null;

        int n = Integer.parseInt(br.readLine());
        PriorityQueue<Table> tables = new PriorityQueue<>();

        // 입력값 저장
        for (int i = 0; i < n; i++) {
            st = new StringTokenizer(br.readLine());
            int s = Integer.parseInt(st.nextToken());
            int t = Integer.parseInt(st.nextToken());
            tables.add(new Table(s, t));
        }

        // 최소 강의실 개수 계산
        PriorityQueue<Integer> pq = new PriorityQueue<>();
        pq.add(0);

        while (!tables.isEmpty()){
            Table cur = tables.poll();
            int earliestEndTime = pq.peek();

            if(cur.s >= earliestEndTime) pq.poll();
            pq.add(cur.t);
        }

        System.out.println(pq.size());
    }
}

'Algorithm' 카테고리의 다른 글

[백준] 2812: 크게 만들기 JAVA  (0) 2023.01.31
[백준] 1715: 카드 정렬하기 JAVA  (0) 2023.01.30
[백준] 1946: 신입 사원 JAVA  (0) 2023.01.28
[백준] 16953: A → B JAVA  (0) 2023.01.28
[백준] 2473: 세 용액 JAVA  (0) 2023.01.18

접근

처음에 문제를 아무리 읽어도 테스트 케이스가 이해가 안됐는데, 입력된 값들을 등수가 아니라 점수로 생각하고 있었다(...) 즉 다른 모든 지원자들과 비교했을 때, 서류와 면접 등수 모두가 남들보다 '큰' 경우에는 제외시켜야 한다. 오늘도 문제를 잘 읽자는 교훈을 얻어간다..

 

이 문제는 단순하게 생각하면 이중 for문을 통해 모든 지원자들을 비교하여 카운트하는 방식으로 풀 수 있다. 하지만 N의 최댓값이 10만이기 때문에, O(N^2)의 시간복잡도를 가지면 시간초과가 나게 된다. 서류 점수로 우선적으로 정렬한 후 뒤쪽 값들과만 비교한다면 시간을 조금은 줄일 수 있겠지만, 그래도 2초 안에 통과하기는 어렵다. 따라서 이중 반복문을 단일로 바꾸는 방법을 생각해봐야 한다. 

 

먼저 서류를 기준으로 등수가 높은 순서대로 정렬해보자. 문제에서 주어진 1번째 테스트케이스 예로 들자. 

서류: 1, 2, 3, 4, 5

면접: 4, 3, 2, 1, 5

서류 점수만 본다면, 어떤 지원자는 무조건 자기보다 오른쪽에 있는 지원자들보다 서류 등수가 높다는 것이 보장된다. 그러니 나보다 왼쪽에 위치한 지원자들과 면접 등수로만 비교하면 되는 것이다. 왼쪽에 있는 지원자들보다 내 면접 등수가 낮다면 절대 합격할 수 없다. 따라서 N만큼 돌면서, 면접 등수의 최솟값을 매번 갱신하며 그 값을 비교해주면 된다. 

 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class BJ1946 {
    static class Rank implements Comparable<Rank> {
        int doc, interview;

        Rank(int doc, int interview){
            this.doc = doc;
            this.interview = interview;
        }

        @Override // 서류 등수가 높은 순서대로 정렬
        public int compareTo(Rank o) {
            return this.doc - o.doc;
        }
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringBuilder sb = new StringBuilder();

        int tc = Integer.parseInt(br.readLine());
        StringTokenizer st = null;

        for (int t = 0; t < tc; t++) {
            int n = Integer.parseInt(br.readLine());
            ArrayList<Rank> arr = new ArrayList<>();

            for (int i = 0; i < n; i++) {
                st = new StringTokenizer(br.readLine());
                int doc = Integer.parseInt(st.nextToken());
                int interview = Integer.parseInt(st.nextToken());
                arr.add(new Rank(doc, interview));
            }
            Collections.sort(arr);

            sb.append(solve(arr)).append("\n");
        }
        System.out.println(sb);
    }

    private static int solve(ArrayList<Rank> arr) {
        int cnt = 0;
        int min = 100001; // N의 최댓값

        for (int i = 0, s = arr.size(); i < s; i++) {
            Rank cur = arr.get(i);
            if(cur.interview < min) cnt++;
            min = Math.min(min, cur.interview);
        }

        return cnt;
    }
}

'Algorithm' 카테고리의 다른 글

[백준] 1715: 카드 정렬하기 JAVA  (0) 2023.01.30
[백준] 11000: 강의실 배정 JAVA  (0) 2023.01.29
[백준] 16953: A → B JAVA  (0) 2023.01.28
[백준] 2473: 세 용액 JAVA  (0) 2023.01.18
[백준] 1253: 좋다 JAVA  (0) 2023.01.18
본문 링크

들어가기에 앞서

36시간 장애 속에서 수백만 명이 서비스 오픈만을 기다리고 있고, DB를 만든 회사에서 복구는 불가능하다고 얘기하는 극한의 상황 속에서 CTO가 커리어를 걸고 비트 레벨까지 내려가서 DB를 해킹했던 이야기

해커 문화(Hacker Culture)

  • ‘해킹’의 개념은 MIT에서 시작되었는데, 소프트웨어 시스템이나 하드웨어의 제약사항을 창조적으로 함께 극복해 내는 행위를 뜻함
  • 문제를 해결하거나 시스템의 한계를 극복하기 위해 시스템의 밑바닥까지 파고들고, 그 시스템에 대한 완벽한 이해를 추구
  • 해커 문화와 오픈소스와는 밀접한 연관이 있음
  • 데브시스터즈의 엔지니어링 조직은 해커 문화와 오픈소스를 좋아하며, 사용하는 오픈소스 제품의 코드 레벨까지 다루곤 함

 


문제 발생

개발 환경

  • 메인 데이터베이스로 CockroachDB를 사용
  • CockroachDB는 전통적인 RDBMS처럼 ACID 특성을 가지고 있으며, SQL 기반의 트랜잭션 처리가 완벽하게 작동하는 분산 DB
  • 런칭 전부터 대규모 사용자 유입을 대비하여 데이터베이스는 24대의 노드, 12TB의 스토리지, 7개의 복제본을 두어 만반의 준비를 갖춤

 

오픈 당시 상황

  • 오픈 직후 어마어마한 사용자 유입이 있었고, 한 주가 채 지나기도 전에 스토리지가 약 8TB 정도 차기 시작
  • DevOps 조직은 런칭 첫 주 주말을 장애 없이 넘어가고, 월요일부터 스토리지가 차는 문제를 대응하기 위한 작업에 들어감
  • 데이터베이스 확장 작업 전에 안전장치를 설정하기 위한 작업을 하던 중 의도치 않은 설정 미스로 인해 데이터베이스 노드 중 절반 이상이 다운

 

문제 발생 이유

  • 앞서 CockroachDB는 트랜잭션을 지원한다고 했는데, CockroachDB 클러스터는 기본적으로 가장 높은 수준의 트랜잭션 일관성을 목표로 설정되어 있음
  • 의도하지 않은 설정 이슈로 인해 절반 이상의 노드들이 비일관성을 탐지하고 에러가 발생하면서 클러스터에서 제외되기 시작
  • 이에 따라 데이터베이스는 트랜잭션 일관성을 지켜줄 수 없다고 판단하고 트랜잭션 처리를 중단하는 일종의 보호 모드로 진입
  • 이후 모든 종류의 SQL 쿼리가 처리되지 않게 되고, 게임 서버는 당연히 데이터베이스가 응답이 없으므로, 클라이언트의 모든 요청 처리를 못하게 됨
  • 즉, 서비스 전체 장애가 발생

 


초기 장애 대응

기술 지원 요청

  • DevOps 팀의 최우선 과제는 데이터베이스를 정상 상태로 복원하는 것
  • 장애 시 기술적인 지원을 받을 수 있는 CockroachDB Enterprise를 사용 중이었기 때문에 기술 지원을 요청
  • 상황을 설명하고, 노드의 로그 메시지를 공유하는 등 해당 업체와 함께 트러블슈팅 및 복구 방안을 찾기 위해 노력
  • 그러나 업체 쪽에서는 복구가 불가능하다는 결론
  • 마지막 백업본으로 데이터베이스를 복구하고 서비스를 재개하는 것을 추천

 

서포트 엔지니어의 판단 근거

  • 기술적인 배경에는 CockroachDB의 설계 철학이 있을 것
  • 다수의 노드가 설정 미스로 인하여 클러스터 참여가 불가해진 시점에선 이미 데이터가 일관되게 저장되어 있음을 보장할 수 없음
  • 불완전한 현재 데이터를 복구하는 것 보다는 일관성이 보장된 상태의 백업으로 복구하는 것을 권장한 것으로 판단됨
  • 만약 CRDB 기반으로 은행 서비스를 개발했다면, 실제로 데이터베이스 복구 작업은 불가하다고 보는 게 맞고, 백업에서 복원하는 게 가장 좋은 선택일지도 모름

 

쿠킹덤의 상황

  • 그러나 쿠킹덤의 경우, 서버의 구조에 Snapshot과 Journal을 사용해 데이터를 커밋 하는 Event Sourcing 기반의 아키텍처를 사용했음
  • 따라서 데이터베이스에 있는 Snapshot, Journal 데이터를 가져올 수만 있다면 확률적으로 거의 완벽한 데이터 복구가 가능하다고 판단
  • 이에 따라 CockroachDB에 저장되어 있는 원시 데이터에서 테이블에 저장된 row들을 복구하는 방법에 대해 문의 → 그런 방법은 존재하지 않는다는 답변
  • 그러나 분명히 데이터는 스토리지에 저장되어 있고, 이를 끄집어낼 수 있는 방법이 있을 거라고 생각하고 이 점을 고민하기 시작함
  • 실제 서비스의 장애 상황에서 이런 접근을 하기 쉽지는 않았으나, DB복구가 불가능할 경우를 대비한 로그 기반의 복구를 플랜B로 따로 진행하고 있었기에 가능했음

 


복구 데이터 탐색

아키텍쳐에 대한 사전 지식

  • CRDB에 저장되어 있는 데이터를 끄집어낼 수 있는 방법을 리서치하기 시작
  • 런칭 전에 CockroachDB 스터디를 진행했기 때문에 CRDB의 아키텍처에 대해서 일부 파악하고 있던 지식들이 있었음
  • 하위 스토리지 레이어는 Pebble로 구성된 Key-Value 스토리지를 사용한다는 사실, 그 위에 Raft 기반의 Consensus Algorithm, 이를 통해 ACID 및 트랜잭션 구현을 했고, 그 위에 SQL 레이어를 구현했다는 것

 

스토리지 살펴보기

  • 실제 노드에 물리적으로 저장된 파일들을 파보기 시작(sst 확장자의 파일들)
    • CockroachDB에서 사용하는 Pebble: Facebook에서 만든 RocksDB의 후계자
      RocksDB: Google에서 만든 LevelDB의 후계자
    • sst 파일은 LevelDB가 스토리지에 데이터를 물리적으로 저장할 때 사용하는 확장자임
  • Pebble의 sst 파일이 RocksDB, LevelDB의 sst 파일과 호환이 될지 살펴봄

 

원하는 데이터 발굴

  • 공식 문서에 따르면, Pebble은 RocksDB와 on-disk format 호환을 목표로 한다고 되어있음
  • 하지만 RocksDB의 경우 LevelDB와 API 레벨에서의 호환성만 얘기하지, on-disk format 에 대한 이야기는 찾아볼 수 없었음
  • 이에 따라 on-disk format이 동일한 RocksDB 의 구조를 파악하기 시작
  • 조금 다르긴 하지만 기본적인 구성은 LevelDB와 유사하며, 확장한 형태로 보였음
  • 실제 온전한 CRDB sst 파일들을 받아서 분석해 보기 시작
  • hexdump 명령어로 원하던 데이터를 발견

 


데이터 파싱

CRDB 구조 파악

  • sst 파일의 뒷부분의 metadata를 읽어야 data block들을 읽을 수 있음
  • 다만 실제 data block들에 들어있는 CRDB의 row 데이터를 저장하는 방식도 파악할 필요를 느낌
  • CRDB 문서를 통해 Column Family라는 개념으로 K-V Store 저장되는 것을 확인
  • 구체적인 저장 방식을 확인하기 위해 CRDB 소스코드 레포지토리 내에 있는 Design Document 들을 읽어봄

 

코드레벨 분석

  • 소스코드를 탐색하다보니 실제 저장에 사용되는 Protocol Buffer 파일을 발견
  • 주석에 따르면 Value 값이 <4-byte-checksum><1-byte-tag><encoded-data> 형태로 저장되고 있음
  • 스토리지에 저장된 파일을 파싱하기 위한 코드도 발견
  • <1-byte-tag>를 읽고 case 문으로 Value Type 별로 별도의 파싱 루틴을 타서 예쁘게 출력을 하는 구조를 확인

 

가능성 검증

  • 실제 데이터가 들어있는 sst 파일을 하나 hexdump로 열어봄
  • 문서를 참고하여 데이터 타입, 길이 등을 지나면 실제로 저장된 데이터 값들이 보임(53~)
  • 이론적으로 파싱이 가능한 것에서, 실제로 손 파싱을 통해 데이터를 파싱해올 수 있음을 증명
  • 이제 7TB의 데이터를 전부 꺼내오는 방법만 고민하면 됨

 


데이터 저장

우선순위 판단

  • 빠르게(장애 상황이기 때문), 그리고 정확하게(사용자의 데이터이기 때문), 좋은 성능으로(7TB) 구현해야 했음
  • 우선순위를 따져보면, 처리 성능보다는 빠르게 구현 가능하면서 정확해야 함
  • 처리 성능은 데브시스터즈 데이터 분산처리 역량과 AWS의 클라우드 컴퓨팅 자원을 믿고 돈으로 성능을 살 수 있다고 판단

 

해결 방안 탐색

  • 결과적으로 채택한 것은 소스코드를 읽어보면서 발견한 PrettyPrint 함수
  • CLI 툴에서 디버깅을 위한 기능에서 사용되는 기술로, 결국 바이너리 sst 파일을 파싱해서 엔지니어가 읽을 수 있는 형태로 터미널에 출력해 주는 함수
  • 이를 변경해서 CSV로 출력하기로 함
  • 가칭 crdb2csv 프로그래밍 작업에 착수

 

CSV 분산 처리

  • 어떻게 하면 7TB의 데이터를 분산처리하여 csv 파일을 만들 수 있을지 고민
  • PySpark 샘플 코드를 작성해 보면서 crdb2csv 프로그램이 왔을 때를 대비하여 분산처리를 준비
  • csv 파일은 텍스트 포맷이고, stdout 출력물을 받으니 처리시간이 오래 걸리는 것은 어쩔 수 없음
  • AWS 클라우드에서 컴퓨팅 자원을 끌어모을 수 있을 만큼 끌어모음
  • 결과적으로 어떻게든 확보한 노드들로 7TB의 데이터를 CSV 파일로 변환하는 데만 4시간이 걸림
  • crdb2csv 프로그램의 오류를 발견하여 이 4시간이 소요되는 작업도 여러 번 반복했음

 

CSV 파일 검증

  • 변환한 파일에 모든 사용자 데이터가 담겨있는지 검증하는 작업도 꽤 오래 걸림
  • 데이터베이스가 사용하는 MVCC 구조상 필요 없는 사용자의 데이터가 다수 있었기 때문에 이를 제거하고, 최신 값을 뽑아냄
  • 이렇게 뽑아낸 데이터를 백업본, 분석 로그와 교차 검증을 하면서 빠진 데이터가 없는지 체크함
  • 결과적으로 뽑아낸 파일에 모든 사용자 데이터가 정확하게 담겨있음을 확인

 


서비스 복구

새로운 클러스터 준비

  • 인프라 조직에서는 새로운 CockroachDB 클러스터를 준비하고 있었음
  • AWS 노드들을 다 끌어다 쓴 탓에, 해당 클러스터를 셋업하는 것도 힘들었음
  • 결과적으로 클러스터 셋업이 완료되고, 확인이 완료된 CSV 파일을 클러스터에 적재함
  • 불러올 데이터가 크다 보니 예상외로 이것도 상당한 시간이 소요됨
  • 이렇게 다시 정상 작동하는 데이터베이스를 만들었고, 이를 전사적으로 테스트를 거쳐서 36시간 만에 서비스를 다시 오픈!

 


회고

  • 이러한 서비스 복구 방식은 전혀 일반적인 방식은 아니라고 생각했음
  • 데이터베이스 업체에서 복구 불가 판정을 내렸으면, 이미 그 시점부터 백업본으로 서비스를 복구하는 소위 ‘백섭'을 하는 게 일반적이었을 것
  • 시간은 다소 오래 걸렸을지라도 이런 방식으로 장애를 복구한 것에는 이유가 있음

 

개발 문화

  • 해커 문화를 가지고 있기 때문에 엔지니어들이 로우 레벨로 뛰어드는 것에 익숙했음
  • 데이터베이스 레벨부터 커널, VM 레벨까지 다양한 경험이 있는 엔지니어들이 있었음
  • 상호 지식공유를 통해 데이터베이스의 다양한 추상화 레벨에 대한 전방위적인 이해가 가능했음
  • 남의 소스코드를 읽고 고치는 것에 익숙한 조직임
  • 오픈소스 프로젝트들을 매일 이용하고, 문제가 있으면 소스코드를 읽고, 필요하다면 직접 고치면서 기여하기 때문에 가능했음

 

조직 구조

  • 데브시스터즈 산하 CTO 조직인 진저랩은 데이터와 인프라 관련 팀들이 한 조직에 속해있음
  • 일반적으로 분산처리 플랫폼은 지표 등의 데이터 분석에 주로 사용되는데, 데이터와 인프라가 한 조직에 있다 보니 분산처리 역량으로 인프라 장애를 극복하는 특별한 시너지가 가능했음

 

다양한 경험

  • 확장성 있는 인프라를 다루는 경험, 대용량의 데이터를 분산처리하는 경험이 풍부했음
  • 장애 대응 경험이 풍부했기 때문에 Plan A/B/C를 동시에 가동하는 등 여러가지 복구 전략을 함께 진행했고, 실제로 두 가지 복구 전략을 혼합하여 완벽하게 장애 복구를 할 수 있었음
  • 조를 나누어서 여러 가지 복구 전략을 동시 실행했기 때문에, 내가 실패하더라도 플랜 B를 실행하는 동료를 믿고 과감하게 용기를 낼 수 있었음

 


국내 반응

부정적

  • 서비스 측면에서, 과연 일부 사용자의 경험을 훼손하고 대다수를 지키는 롤백보다 36시간에 걸쳐서 모든 사용자 정보를 복구하는것이 비즈니스적으로 옳은 판단일까?
  • 만약에 문제를 해결하지 못했으면 시간은 시간대로 낭비하고 백섭은 백섭대로 해서 원망을 들었을 것
  • 해당 프로젝트에서는 CockroachDB를 사용하지 말았어야 했다는 의견이 다수

 

긍정적

  • 요즘 게임에서 (특히 랜덤 뽑기류가 있는 게임에서) 롤백은 진짜 최악의 경우에만 쓸 수 있는 방법으로, 주로 보상을 크게 주는 편이기 때문에 옳은 판단이었다
  • 게임이다보니 36시간 전으로 롤백하면 캐쉬 관련으로 금전적 손해가 많았을 수도 있었을 것
  • 적절한 기술 기반과 준비된 아키텍처로 어떻게든 백섭이라는 "최악의 사태"를 회피한건 분명한 엔지니어링 성과로 평가받아야 함

 

부정 의견에 대한 반박

  • 36시간 다운타임을 기대매출로 환산했을 때와, 롤백 후 보상지급액을 비교하고 결정한 사안이었을 것. 문제 해결을 중시하는 개발자 문화가 강한 조직이라는 점도 어느정도 영향이 있었을 것 같다
  • DB를 정할 때 저만한 프로젝트에서 어련히 벤치마크 안했을까. 그저 익숙한 기술을 썼으면 안터졌을거라는 나이브한 주장은 너무 반엔지니어링적인 사고

접근

bfs로 큐에 넣어가며 하나씩 확인하다가 b와 똑같은 수가 나온다면, 이는 필요한 연산의 최솟값이 되기 때문에 바로 그 값을 리턴한다. 만약 큐에서 뽑은 수가 b보다 크다면, 가능한 연산은 모두 현재 수보다 증가하는 것이므로 그냥 넘어가도록 한다. 그렇게 a로 만들 수 있으면서 b보다 작은 모든 수를 확인했는데도 b가 만들어지지 않았다면, 해당 수를 만들 수 없는 경우이기 때문에 -1를 리턴한다.

 

처음에 단순히 A, B의 범위만 봤을 때는 int로 처리가 가능할 것이라 생각했는데, 연산 중에 int 범위를 넘어가면서 오버플로우가 발생하는 경우가 있었다. (9억에 10을 곱하고 1을 더하면 int 최대값인 약 21억을 넘어가게 됨)  

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class BJ16953 {
    static class ConvNum {
        long num; // 오버플로우 방지
        int cnt;

        ConvNum(long num, int cnt){
            this.num = num;
            this.cnt = cnt;
        }
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        int a = Integer.parseInt(st.nextToken());
        int b = Integer.parseInt(st.nextToken());

        System.out.println(bfs(a, b));
    }

    private static int bfs(int a, int b) {
        Queue<ConvNum> q = new LinkedList<>();
        q.add(new ConvNum(a, 0));

        while(!q.isEmpty()){
            ConvNum cur = q.poll();

            if(cur.num == b) return cur.cnt + 1;
            else if(cur.num > b) continue; // 더 큰 수는 연산할 필요가 없음

            q.add(new ConvNum(cur.num*2, cur.cnt+1));
            q.add(new ConvNum(cur.num*10+1, cur.cnt+1));
        }

        return -1; // 만들 수 없는 경우
    }
}

'Algorithm' 카테고리의 다른 글

[백준] 11000: 강의실 배정 JAVA  (0) 2023.01.29
[백준] 1946: 신입 사원 JAVA  (0) 2023.01.28
[백준] 2473: 세 용액 JAVA  (0) 2023.01.18
[백준] 1253: 좋다 JAVA  (0) 2023.01.18
[백준] 2467: 용액 JAVA  (0) 2023.01.18

+ Recent posts