하한(Lower bound), 상한(Upper bound)

하한, 상한은 배열이 이미 정렬된 상태에서 적용되어야합니다. 상한, 하한은 모두 이진 탐색(Binary Search)로 구현이 되어있기 때문이죠. 여기서 이진 탐색은 아래의 코드인 것을 알고 계시겠죠? 이진 탐색의 기본 코드는 아래와 같습니다. 이진 탐색의 탐색 속도는 O(lg 2)의 속도를 자랑하지요. 

 

 

int BinarySearch(int s, int e, int number)
{
    int left=s,right=e,mid;
    while (left <= right)
    {
        mid = (left + right) / 2;
        if (arr[mid] == number) return mid;
        if (arr[mid] > n) right = mid -1;
        else left = mid + 1;
    }
    return -1;
}

 

하한이란? (Lower Bound)

특정 값 K보다 같거나 큰 값이 처음 나오는 위치가 됩니다. 즉, K값 이상인 값이 맨 처음 나오는 위치여야합니다. 아래는 그 예를 나타내는 표입니다. 이때 맨 앞 요소의 index는 0으로 시작합니다.

배열
1 2 2 4 4 5 6 6 6 9 9
lower_bound
lower bound( 4 )  3
lower bound( 5 )  5
lower bound( 9 )  9

하한을 Binary Search방식으로 구현한 코드는 아래와 같은데요.  number값을 이상인 값이 mid 위치에서 발견되었다면  왼쪽으로 더 가다보면 아예 작은 수를 만나게 되고 left와 right의 역전 현상이 발생되어 멈춥니다. 멈추기 전에 우리는 가장 마지막에 arr[mid] >= number인 상황을 lower_bnd에 저장한 상황이죠. 

int Binary_Search_Lower_Bound(int s, int e, int number)
{
    int left=s,right=e,mid;
    int lower_bnd = -1;
    while (left <= right)
    {
        mid = (left + right) / 2;
        if (arr[mid] >= number)
        {
            lower_bnd = mid;
            right = mid - 1;
        }
        else left = mid + 1;
    }
    return lower_bnd;
}

범위를 좁혀가는 상황을 그림으로 표현했습니다.

 

- C++의 lower_bound 사용

C++에서는 라이브러리 함수 형태로 lower bound를 지원합니다. 아래와 같은 형식을 사용하게 되는데요. 사용법은 이렇습니다. 


lower_bound( 정렬한 배열 포인터, 정렬한 배열 포인터의 끝 주소, 값) - 정렬한 배열 포인터

여기서 정렬한 배열의 포인터는 알겠는데, 정렬한 배열 포인터의 끝 주소값은 어떻게 구할까요? 그냥 마지막 원소의 주소를 넣어주거나 배열의 주소 + 배열의 사이즈로 넣어주면 됩니다. 

lower_bound의 반환 값은 하한값의 주소를 돌려주기 때문에 만약 index를 얻으려면 정렬한 배열 포인터를 빼주면 됩니다. 이 함수의 사용법은 밑의 예제 코드에서 upper_bound의 함수와 같이 사용해보도록 하겠습니다.

상한이란? (Upper Bound)

특정 값 K보다 큰 값이 처음으로 나오는 위치가 됩니다. 즉, K값 초과인 값이 맨 처음으로 나오는 위치입니다. 몇 가지 예를 들어볼까요?

배열
1 2 2 4 4 5 6 6 6 9 9
upper_bound
upper bound( 4 ) 5
upper bound( 5 ) 6
upper bound( 9 ) -1 

이 역시 Binary Search로 구현되는데요. 구현한 모습은 아래와 같습니다. 중간의 비교문이 반대로 됐음을 알 수 있죠? 이것은 arr[mid]의 값이 number의 이하일때 number와 최대한 같은 값의 index를 찾기 위함입니다.

 

 

int Binary_Search_Upper_Bound(int s, int e, int number)
{
    int left=s,right=e,mid;
    int upper_bnd=-1;
    while (left <= right)
    {
        mid = (left + right) / 2;
        if (arr[mid] <= number)
        {
            upper_bnd = mid;
            left = mid + 1;
        }
        else right = mid - 1;
    }
    return upper_bnd+1 >= N ? -1: upper_bnd+1;
}

 

만약 이렇게 돼서 while루프가 멈추었다면 arr[upper_bnd]값은 number값 초과 직전의 값일 것입니다. 그렇다면 arr[upper_bnd+1]의 값은 number보다 큰 값일 테지요. 그래서 마지막 return에서 +1을 해주는데, 이것이 배열의 사이즈를 넘어갈 수 있으니까 사이즈 체크하여 return합니다. 

C++에서 당연히 lower_bound함수가 존재하듯 upper_bound의 함수 역시 존재하며 사용법은 동일합니다. 단, 아래와 같이 사용할때 상한 값을 못찾아줄때도 있는데, 이때는 배열의 인덱스를 넘어가는 값을 갖게됩니다.


  upper_bound(정렬한 배열 포인터, 정렬한 배열 포인터의 끝 주소, 값) - 정렬한 배열 포인터

 

이제까지 설명한 내용을 예제 코드를 통해서 확인해보세요. 직접 구현한 BinarySearch 형식의 upper_bound, lower_bound도 같이 구현되어있습니다.

#include <iostream>
#include <algorithm>

using namespace std;

int arr[100];
int N = 11;
int BinarySearchLowerBound(int s, int e, int number)
{
    int left=s,right=e,mid;
    int lower_bnd = -1;
    while (left <= right)
    {
        mid = (left + right) / 2;
        if (arr[mid] >= number)
        {
            lower_bnd = mid;
            right = mid - 1;
        }
        else left = mid + 1;
    }
    return lower_bnd;
}

int BinarySearchUpperBound(int s, int e, int number)
{
    int left=s,right=e,mid;
    int upper_bnd=-1;
    while (left <= right)
    {
        mid = (left + right) / 2;
        if (arr[mid] <= number)
        {
            upper_bnd = mid;
            left = mid + 1;
        }
        else right = mid - 1;
    }
    return upper_bnd+1 >= N ? -1: upper_bnd+1;
}


int main(void) {


    arr[0] = 4; arr[1] = 2; arr[2] = 4; arr[3] = 1; arr[4] = 9;
    arr[5] = 2; arr[6] = 6; arr[7] = 6; arr[8] = 6; arr[9] = 9, arr[10] = 5;
    
    //정렬
    sort(arr, arr + N);

    printf("정렬된 배열\n");
    for (int i = 0; i < N; i++) printf("%d ",arr[i]);
    printf("\n");
    
    printf("Lower Bound\n");

    printf("%d lower bound:%d\n", 4, BinarySearchLowerBound(0, N - 1, 4));
    printf("%d lower bound:%d\n", 5, BinarySearchLowerBound(0, N - 1, 5));
    printf("%d lower bound:%d\n", 2, BinarySearchLowerBound(0, N - 1, 2));
    printf("%d lower bound:%d\n", 1, BinarySearchLowerBound(0, N - 1, 1));
    printf("%d lower bound:%d\n", 9, BinarySearchLowerBound(0, N - 1, 9));

    printf("%d lower bound:%d\n", 4, lower_bound(arr, arr + N, 4) - arr);
    printf("%d lower bound:%d\n", 5, lower_bound(arr, arr + N, 5) - arr);
    printf("%d lower bound:%d\n", 2, lower_bound(arr, arr + N, 2) - arr);
    printf("%d lower bound:%d\n", 1, lower_bound(arr, arr + N, 1) - arr);
    printf("%d lower bound:%d\n", 9, lower_bound(arr, arr + N, 9) - arr);
  

    printf("Upper Bound\n");

    printf("%d upper bound:%d\n", 4, BinarySearchUpperBound(0, N - 1, 4));
    printf("%d upper bound:%d\n", 5, BinarySearchUpperBound(0, N - 1, 5));
    printf("%d upper bound:%d\n", 2, BinarySearchUpperBound(0, N - 1, 2));
    printf("%d upper bound:%d\n", 1, BinarySearchUpperBound(0, N - 1, 1));
    printf("%d upper bound:%d\n", 9, BinarySearchUpperBound(0, N - 1, 9));

    printf("%d upper bound:%d\n", 4, upper_bound(arr, arr + N, 4) - arr);
    printf("%d upper bound:%d\n", 5, upper_bound(arr, arr + N, 5) - arr);
    printf("%d upper bound:%d\n", 2, upper_bound(arr, arr + N, 2) - arr);
    printf("%d upper bound:%d\n", 1, upper_bound(arr, arr + N, 1) - arr);
    printf("%d upper bound:%d\n", 9, upper_bound(arr, arr + N, 9) - arr);   //배열의 인덱스 초과된 값이 나옴
  
}

 

 

그리고 위 코드의 결과입니다. 마지막 upper_bound의 값이 다름을 확인해보세요.

 

 

전체 코드의 결과

 

반응형
블로그 이미지

REAKWON

와나진짜

,

 

상호배타적 집합(Disjoint Set)

오리엔테이션이나 수학 여행 등에서 서로 친해지기 위해서 하는 활동 중에 이런 활동들을 한번쯤 해보셨을 겁니다. 다들 서로 낯서고 모르는 상태이기 때문에 어색어색하죠. 이 순간은 서로가 각각 혼자서 있습니다. 그럴때 사회자가 혈액형이 같은 사람끼리 모이라고 했을때, A, B, AB, O 형으로 나뉘어진 각 사람들은 같은 혈액형을 찾으려고 시도하겠죠? 그래서 같으면 같은 조로 묶이게 됩니다.

이처럼 같은 특성끼리 서로 모이는 집합이 여러 집합이 있는데, 각 집합에서는 공통 원소가 존재하지 않는 그런 집합을 상호배타적 집합(Disjoint Set)이라고 합니다. 한 집합에서 다른 특성을 갖는 집합을 배제한다는 뜻입니다. 이때 쓰이는 자료구조가 유니온-파인드(Union-Find) 자료구조라고 합니다.

구현

상호배타적 집합을 구현하기 위해서는 트리의 자료구조를 사용합니다. 각 트리 노드들은 루트 노드를 가지고 있지요. 그래서 그 루트노드를 기준으로 같다면 같은 집합에 속해있는 것이고, 다르다면 다른 집합에 속해있다는 것을 알 수 있습니다.

1. 초기화

초기의 상태는 각 노드들이 자신이 루트가 됩니다. 아직 어떤 노드도 만난적이 없으니까 집합에서의 리더는 자기 자신일테니까요. 그러다가 같은 속성이 있다면 서로를 합치게 되는 것이죠. 아래의 코드가 초기화 코드를 나타냅니다. parent는 자신의 상위 트리 노드를 말하며 처음에는 자기 자신이 됩니다. 나머지 rank 변수는 이 후 설명하도록 하겠습니다.

 

struct DisjointSet {
	vector<int> parent, rank;
	DisjointSet(int n) :parent(n), rank(n, 1) {
		for (int i = 0; i < n; i++)
			parent[i] = i;
	}
};

 

아래는 두 트리가 있음을 보여줍니다. 두 트리의 루트는 1과 6이라서 이 트리들은 서로 다른 집합을 나타내는 것이라고 할 수 있습니다.

루트가 다른 트리

 

2. 루트 찾기

여기서 각 루트를 찾는 코드는 아래와 같습니다. 

int find(int node) {
	if (node == parent[node]) return node;
	return parent[node] = find(parent[node]);
}

 

find는 현재 노드가 속해있는 트리의 루트를 구하는 함수인데요. 여기서 재귀적으로 find를 호출해서 부모를 계속 찾다보면 나중에는 결국 부모가 자기 자신인 노드를 발견하게 됩니다. 왜냐면 루트의 부모는 없으며 루트는 위의 초기화에서 자기 자신이 부모 노드입니다. 그리고 반복적으로 find를 계속 호출하지 않기 위해서 parent[node] = find(parent[node]) 로 부모를 계산된 루트로 기록해둡니다. 그러면 나중에 루트를 찾을때 단번에 루트로 가서 반환되게 때문에 최적화를 할 수 있습니다.

3. 트리 합치기

루트가 다른 이 트리, 알보고니 공통 속성이 있어서 합쳐야될 것 같습니다. 합쳐보긴할텐데 어느쪽으로 합쳐야될까요? 우리는 루트가 1인 트리를 루트가 6인 트리에 합쳐야할까요? 아니면 6인 트리를 1인 트리 밑으로 가게 만들까요? 우리가 find를 구현했을때 루트를 찾이 위해서 계속 재귀적으로 부모를 호출해가면서 확인해보았습니다. 

그렇다면 루트를 빨리 찾기 위해서는 노드의 부모수가 적으면 속도가 빨라지겠죠. 그런 원리로 최적화하면 됩니다. 즉, 트리의 높이를 작게 만들수록 유리합니다. 

루트가 다른 트리

 

1이 루트인 트리에 6이 루트인 트리를 밑에 합쳐놓은 상황입니다. 트리의 높이가 증가했음을 알 수 있죠? 그렇다면 반대로 합쳐놓는다면, 즉 6이 루트인 트리에 1이 루트인 트리를 보면 어떨까요?

 

트리의 높이 증가

 

아래의 그림이 그 상황을 보여줍니다. 트리의 높이가 증가하지 않았음을 볼 수 있습니다. 그렇다면 결국에는 트리의 높이가 큰 트리 밑에 작은 트리를 합쳐 놓는게 높이를 증가시키지 않는 방법이네요. 이제 구현해봅시다.

트리의 높이 증가하지 않음

 

아래의 구현이 병합하는 코드를 구현한 것입니다.

void merge(int left, int right) {
	left = find(left), right = find(right);
	if (left == right) return;
	if (rank[left] > rank[right]) swap(left, right);
	parent[left] = right;
	if (rank[left] == rank[right]) ++rank[right];
}

 

왼쪽 트리가 항상 오른쪽 트리의 자식이 될 수 있도록 구현한 코드인데요. 왼쪽 트리의 루트를 구하고, 오른쪽 트리의 루트를 구해서 같으면 이미 같은 트리에 속해있는 것으로 종료합니다. 

그 후 rank라는 배열을 통해서 트리의 높이를 얻어옵니다. 항상 왼쪽에 작아서 오른쪽으로 합칠 구현을 해야하니까 값이 왼쪽이 크다면 교환해줍니다. 그래서 결국 왼쪽 부모는 오른쪽의 부모와 같게 만들죠.

트리의 높이가 증가하는 경우는 왼쪽, 오른쪽 트리의 rank값이 같을때만 존재합니다.

전체 소스코드는 아래와 같게됩니다. 아래의 코드는 알고리즘 문제해결 전략, 구종만 님의 코드를 참고했습니다.

 

struct DisjointSet {
	vector<int> parent, rank;
	DisjointSet(int n) :parent(n), rank(n, 1) {
		for (int i = 0; i < n; i++)
			parent[i] = i;
	}

	int find(int node) {
		if (node == parent[node]) return node;
		return parent[node] = find(parent[node]);
	}

	void merge(int left, int right) {
		left = find(left), right = find(right);
		if (left == right) return;
		if (rank[left] > rank[right]) swap(left, right);
		parent[left] = right;
		if (rank[left] == rank[right]) ++rank[right];
	}
};

 

예제 : BOJ 1717번, 집합의 표현

정확한 이해를 돕기 위해서 아래의 문제를 풀어봅시다. DisjointSet을 이용해서 풀 수 있는 문제로 위의 동작을 이해할 수 있으면 거뜬하게 풀 수 있는 문제입니다.

www.acmicpc.net/problem/1717

 

1717번: 집합의 표현

첫째 줄에 n(1 ≤ n ≤ 1,000,000), m(1 ≤ m ≤ 100,000)이 주어진다. m은 입력으로 주어지는 연산의 개수이다. 다음 m개의 줄에는 각각의 연산이 주어진다. 합집합은 0 a b의 형태로 입력이 주어진다. 이는

www.acmicpc.net

 

문제의 입력은 아래와 같습니다.

7 8
0 1 3
1 1 7
0 7 6
1 7 1
0 3 7
0 4 2
0 1 1
1 1 1

 

처음 N과 M이 주어지며 N은 집합의 원소 중 가장 마지막 원소, M은 질의를 나타냅니다.

다음은 질의가 M가 나오는데 질의의 유형은 0 a b, 1 a b가 있습니다. 0 a b는 a가 포함된 집합, b가 포함된 집합을 합치는 연산이며 1 a b는 a와 b가 같은 집합에 속해있는지 확인하는 연산입니다. 이때 1 a b의 질의에서 같은 집합에 속한다면 YES를 출력, 아니면 NO를 출력하면 됩니다.

위의 입력에 대한 출력은 아래와 같습니다.

NO
NO
YES

 

풀이 

맨 처음에는 각각의 수들은 다른 집합에 속해있다가 연산 0이 나오면 합치면 됩니다. 이 역할이 DisjointSet의 merge가 되겠군요. 그리고 연산 1이면 a와 b가 같은 집합에 속해있는지 확인하는 연산이므로 find(a), find(b)를 통해 두 집합의 root를 구한후 같은지 다른지 확인하면 되는 간단한 문제입니다.

문제의 정답 코드는 이렇습니다.

#include <cstdio>
#include <vector>
using namespace std;
#define MERGE 0
#define SAME_GROUP 1

struct DisjointSet {
	vector<int> parent, rank;
	DisjointSet(int n) :parent(n), rank(n, 1) {
		for (int i = 0; i < n; i++)
			parent[i] = i;
	}

	int find(int node) {
		if (node == parent[node]) return node;
		return parent[node] = find(parent[node]);
	}

	void merge(int left, int right) {
		left = find(left), right = find(right);
		if (left == right) return;
		if (rank[left] > rank[right]) swap(left, right);
		parent[left] = right;
		if (rank[left] == rank[right]) ++rank[right];
	}
};

int main() {
	int N, M;
	scanf("%d %d", &N, &M);
	DisjointSet djs(N+1);
	for (int i = 0; i < M; i++) {
		int query, a, b;
		scanf("%d %d %d", &query, &a, &b);
		if (query == MERGE) djs.merge(a, b);
		
		if (query == SAME_GROUP) 
			printf("%s\n", djs.find(a) == djs.find(b) ? "YES" : "NO");
	}

}

 

이상으로 상호배타적 집합에 대한 개념과 코드 구현, DisjointSet을 이용해서 문제를 풀어보았습니다. DisjointSet 알고 있으면 도움이 될 것 같나요?

반응형
블로그 이미지

REAKWON

와나진짜

,

가끔 스마트폰 사용하다가 이상하게 용량을 잡아먹지 않나요? 제가 갤럭시 S8 기종을 쓰고 있는데, 이게 저장공간이 그렇게 충분하지가 않더군요. 바꿀때가 된건지... 그럴때 제가 사용하는 용량 확보방법 몇가지 소개시켜드리도록 하겠습니다. 제 스마트폰(갤럭시 S8)을 기준으로 설명할건데, 다른 기종도 비슷할거니까 따라해보세요.

1. 안쓰는 어플리케이션 삭제

가장 간단한 방법으로 누구나 다 알고있는 내용입니다. 설정 -> 디바이스 케어 -> 저장공간 으로 이동해보시면 저장공간에 남아있는 용량이 나와있으며 사진, 문서, 어플리케이션들이 어느정도의 저장공간을 차지하고 있는지 파악할 수 있습니다. 여기서 안쓰는 어플리케이션이나 사진을 삭제해주세요.

 

 

저장 공간 클릭

 

삭제할 카테고리 클릭

어플리케이션 말고도 문서도 있고 이미지, 동영상도 있는데, 저는 주기적으로 컴퓨터로 백업해서 핸드폰 저장공간을 확보하는 편입니다.

 

2. 어플리케이션 캐시 삭제

우선 어플리케이션을 사용하다보면 그 놈들이 사용하는 일종의 저장공간이 있는데요. 캐시 데이터는 자꾸자꾸 쌓이게 되니까 저는 주기적으로 지워줍니다. 그 중 가장 큰 용량을 차지했던 것은 구글 플레이 스토어였습니다. 저는 어플도 자주 설치하지 않는데, 왜 자꾸 캐시를 쳐먹는지는 모르겠지만요. 얘가 좀 양아치라서 필요하시면 캐시 삭제하시는 것을 추천합니다. 구글 플레이 스토어를 길게 누르면 어플리케이션 정보를 볼 수 있는데요. 그것을 터치해줍니다.

앱정보 들어가기

그러면 저장공간이 보이는데 이것을 눌러서 들어갑니다.

저장공간 들어가기

이후에 캐시 데이터가 보이시나요? 앱 용량은 100MB가 안되는데 캐시만 주구 장창쓰고 있음을 알 수 있습니다. 저는 2달 정도 캐시를 안지우니 4.5GB 정도 캐시가 쌓였군요. 캐시 삭제를 눌러 지워줍니다. 제가 실행한 바로는 캐시 지워도 별다른 치명적인 기능 저하는 없었습니다.

 

 

더럽게 많이 쳐먹네

저는 구글 플레이 스토어외에도 크롬, 유튜브도 이런식으로 캐시 데이터를 삭제했습니다. 그런데 자주 사용하는 앱들이라서 금방 차게 되더군요.

3. 카카오톡 데이터 지우기

국민 어플 카카오톡을 많이 사용하는 만큼 그 기능도 많은데요. 그만큼 데이터를 저의 핸드폰 저장 영역을 이용해 데이터를 저장해놓습니다. 저같은 아싸들이야 뭐 채팅창이 몇개 없으니까 상관없지만, 여러분들같은 사회성 좋으신 분들은 채팅방을 관리해주는 것이 좋습니다. 

우선 채팅방을 열도록 해요. 그러면 오른쪽 위에 메뉴(三)가 보이시나요? 누르면 여러메뉴가 있는데요. 

 

제가 관심있는 것은 사진, 동영상, 파일 등 주고받았던 파일들입니다. 저는 사진, 동영상을 삭제해보겠습니다. 사진, 동영상쪽으로 들어가봅시다. 주의하실점은 사진, 동영상은 삭제되면 복구할 수 없으니까 필요없는 파일이나 사진을 삭제하셔야합니다! 사진이나 동영상은 백업해주시고 삭제해주세요.

 

여기에 우측 상단에 관리가 있습니다. 여기서 모두 삭제를 누르시면 전부 삭제되서 저장공간이 확보됩니다. 

 

 

관리 클릭

 

아까도 이야기했지만 삭제한 파일은 되돌릴 수 없으니 주의하셔서 삭제해주세요.

 

여기까지만 하셔도 왠만하게 스마트폰 저장공간을 확보해보실 수 있을 겁니다. 혹시 이 방법외에도 본인들이 사용하는 저장공간 확보하는 방법이 있으시면 댓글로 공유부탁드립니다! 저도 배우고 요긴하게 사용하도록 해보겠습니다.

반응형
블로그 이미지

REAKWON

와나진짜

,

 

 

동적 메모리 할당,해제를 맡는 new, delete 키워드

C에서 메모리의 동적할당을 할때 malloc 함수를 사용하지요. 동적할당에 대해 모르신다면 저의 동적할당에 대한 포스팅을 보고 오시기 바랍니다.

reakwon.tistory.com/20

 

[C언어] 동적 메모리 할당의 세가지 방법 malloc, calloc, realloc

동적 메모리 할당 우리는 이제껏 메모리를 할당할때 정적으로 할당했습니다. 어떤 것이냐면 int arr[100]; 이렇게 할당을 했었죠. 뭐 문제없습니다. 실행도 잘 되구요. 하지만 이런 상황은 조금 불

reakwon.tistory.com

 

C언어에서 malloc을 사용할때 반환하는 void*를 형변환하여 사용하였습니다. 그리고 동적할당된 메모리를 해제할땐 free를 사용했습니다. C++에서는 이러한 동적할당에 관한 사용이 자주 이루어지기 때문에 아예 키워드로 지정해놓았습니다. malloc을 대체하는 키워드는 new, free를 대체하는 키워드는 delete입니다. 사용되는 방식은 다음과 같습니다.

MyClass* myclass = new MyClass();
delete(myclass);

여기서 malloc과 new는 동적할당하는 것은 같지만 malloc은 함수를 call하는 것이고 new는 C++에서 기본적으로 제공하는 키워드이기 때문에 별도의 라이브러리를 추가할 필요가 없습니다. 또한 new는 생성자를 자동호출하는 특징이 있지요. 이제 C++에서는 객체를 할당할때 malloc으로 하지 않고 new를 이용하여 할당합니다.

오버라이딩(Overriding)

Class의 상속에 대해서 배우셨나요? 그렇다면 이제 다형성을 배워볼 차례입니다. 다형성이라하면 여러 형태를 갖는 객체의 특징을 말하는데요. 아래의 코드를 가지고 다형성과 관련한 클래스의 특징들을 천천히 살펴보도록 합시다.

#include<iostream>
using namespace std;

class Animal {
private:
	int height;
	int weight;
public:
	Animal(int _height, int _weight) :height(_height), weight(_weight) {}
	void printInfo() {
		cout << "==============정보=============" << endl; ;
		cout << "키:" << height << "무게:" << weight << endl;
	}
	int getHeight() {
		return height;
	}
	int getWeight() {
		return weight;
	}
};

class Human :public Animal {
private:
	int race;
public:
	Human(int _height, int _weight, int _race) :Animal(_height, _weight) {
		race = _race;
	}
	void printInfo() {
		cout << "==============정보=============" << endl;
		cout << "키:" << getHeight() << "무게:" << getWeight() << endl;
		cout << "인종:";
		if (race == 0)
			cout << "황인" << endl;
		else if (race == 1)
			cout << "흑인" << endl;
		else if (race == 2)
			cout << "백인" << endl;
		else
			cout << "혼혈" << endl;
	}
};
int main() {
	
	Animal* animal = new Animal(50, 20);
	animal->printInfo();

	Human* human = new Human(150, 80, 3);
	human->printInfo();
	delete(animal);
	delete(human);
}

 

여기서 Animal클래스는 Human클래스의 부모 클래스입니다. main에서는 둘 다 new 키워드로 생성해서 정보를 출력하는 코드네요. Human 클래스에서 printInfo 메소드를 보면 cout 두줄이 정확히 Animal의 printInfo클래스의 메소드와 같은 것을 알 수 있습니다. 

어차피 Human클래스는 Animal클래스를 상속받고 있으니, Animal클래스의 printInfo를 재활용 할 순 없을까요? 그럴때 부모클래스::메소드명 을 호출하여 부모클래스의 메소드를 사용할 수 있습니다. Human 클래스의 메소드를 아래처럼 바꿔봅시다.

void printInfo() {
	Animal::printInfo();
	cout << "인종:";
	if (race == 0)
		cout << "황인" << endl;
	else if (race == 1)
		cout << "흑인" << endl;
	else if (race == 2)
		cout << "백인" << endl;
	else
		cout << "혼혈" << endl;
}

 

이처럼 부모클래스의 함수를 이용하여 자식 클래스에서 같은 메소드 이름으로 새로운 기능을 덧붙이는 방식을 오버라이딩이라고 합니다.

 

 

다형성(Polymorphism)

OOP(Object-Oriented Programming)은 현실 세계를 반영한다는 슬로건을 가지고 있습니다. 위의 코드를 현실세계와 연관지어 생각해봅시다. 우리 사람은 동물의 한 종이죠. 그래서 동물의 속성을 갖고 있습니다. 하지만 동물은 사람이 아니죠. 동물에는 사람외에도 개와 고양이 등 많이 있기 때문인데요. 정리하면 이렇게 되겠네요.

사람은 동물이다.(O)

동물은 사람이다.(X)

위에서 성립이 되는 상황(사람은 동물)을 C++에서는 아래와 같이 표현할 수 있습니다. main함수안의 내용을 아래와 같이 바꿔서 실행해보시기 바랍니다.

Animal* human = new Human(150, 80, 3);
human->printInfo();

 

이렇게 부모클래스를 통해 만들어진 객체가 자신을 상속받는 여러 클래스의 객체로 모양을 띄는 것을 다형성이라고 합니다. OOP에서 매우 중요한 개념입니다.

위의 코드를 수행한 결과에서 한가지 불편한 점이 있는데요. 저희는 human->printInfo()가 당연히 Human클래스의 printInfo를 출력한다는 믿음으로 프로그래밍을 했는데, 막상 결과는 Animal의 printInfo가 호출됩니다. 어떻게 해야지 우리가 원하는 동작을 할 수 있을까요?

virtual 키워드

만약 자신을 상속받는 자식 클래스의 객체가 자신의 메소드를 사용하는데, 이것을 오버라이딩했다면 자식 클래스의 객체 메소드를 호출하라고 지정하는 방법은 메소드 앞에 virtual 키워드를 사용하는 것입니다. 자신의 메소드는 가상으로 만들어져 있으니 자식의 메소드를 호출하라는 의미가 되겠죠. 그래서 우리의 목표를 달성하기 위해서는 Animal 클래스의 printInfo메소드 앞에 virtual 키워드를 추가하면 됩니다. 그렇게 되면 Human의 printInfo 메소드를 호출하게 됩니다.

class Animal {
private:
	int height;
	int weight;
public:
	Animal(int _height, int _weight) :height(_height), weight(_weight) {}
	virtual void printInfo() {
		cout << "==============정보=============" << endl; ;
		cout << "키:" << height << "무게:" << weight << endl;
	}
	int getHeight() {
		return height;
	}
	int getWeight() {
		return weight;
	}
};

 

virual키워드를 사용해야 자식 객체의 메소드를 사용한다고 했죠? 그렇다면 다음의 상황을 예측해봅시다.

class Human :public Animal {
private:
	int race;
public:
	//...생략...//
	void printInfo() {
		//...생략...//
	}
};

class Student :public Human {
private:
	char grade;
public:
	Student(int _height, int _weight, int _race,char _grade) :Human(_height, _weight, _race) {
		grade = _grade;
	}

	void printInfo() {
		Human::printInfo();
		cout << "성적:" << grade << endl;
	}
};

 

Human 클래스는 printInfo에 virtual키워드를 넣지 않았습니다. Student는 Human클래스를 상속받고 있는데, 이때 printInfo는 Human의 printInfo를 호출할까요? 메소드의 virtual이 지정되면 이후 자식은 자동으로 virtual 키워드가 적용이 됩니다. 그래서 결론은 Student의 printInfo가 호출이 되지요. 하지만 명시적으로 virtual을 지정해주는 것이 관례입니다. 코드의 가독성과 이해를 돕기 위해서지요.

자, 이런 다형성은 언제 필요할까요? 예를 들면 함수에서 해당 클래스를 상속하는 모든 객체를 받을 때가 그 예가 됩니다. 아래의 함수에서 변수로 animal를 가리키는 포인터를 받습니다. 이 함수의 목적은 animal인 객체의 printInfo를 출력하는 것인데, Animal클래스를 상속하는 모든 객체를 받을 수 있습니다. 

void printInfo(Animal *animal) {
	animal->printInfo();
}

 

 

this 포인터

객체가 생성될때 자기 자신을 가리키는 포인터가 this 포인터라고 합니다. 만일 아래와 같은 상황에서 this를 사용할 수 있습니다. 저는 생성자에서 객체의 grade를 전달받은 grade의 값으로 입력하고 싶어서 아래와 같은 코드를 사용했습니다.

class Student :public Human {
private:
	char grade;
	Student(int _height, int _weight, int _race,char grade) :Human(_height, _weight, _race) {
		grade = grade;
	}
//...생략...//
};

 

여기서 객체의 grade는 절대 변할 수 없습니다. 객체의 grade보다 매개 변수인 grade가 우선시 되기 때문에 매개변수 grade에 다시 매개변수의 grade의 값을 넣기 때문입니다. 이럴때 사용할 수 있는 것이 바로 this포인터를 사용하는 것이죠.

Student(int _height, int _weight, int _race,char grade) :Human(_height, _weight, _race) {
	this->grade = grade;
}

this포인터는 객체가 생성되고 난 이후에 그 효력을 발휘합니다. 그 객체의 주소를 나타내야하기 때문이죠. 아래와 같이 한번 주소를 찍어봅시다. 정확히 같은 곳을 가리키는 것을 알 수 있습니다.

class Student :public Human {
private:
	char grade;
public:
	Student(int _height, int _weight, int _race,char grade) :Human(_height, _weight, _race) {
		cout <<"this 포인터의 주소"<< this << endl;
		this->grade = grade;
	}
	//...생략...//
};

int main() {
	
	Animal* student = new Student(165, 55, 0, 'A');
	cout <<"student의 주소"<< student << endl;
	student->printInfo();
	delete(student);
}

 

이상 다형성과 관련된 포스팅을 마치도록 하겠습니다. 이해가 되지 않는 부분은 댓글로 남겨주세요.

 

 

반응형
블로그 이미지

REAKWON

와나진짜

,

 

 

C++ 상속(Inheritance)

다른 여타의 객체지향언어와 같이 C++ 역시 상속을 할 수 있습니다. 여러분은 현실 생활에서 상속이라는 개념을 알고있으신가요? 부모님으로부터 100억을 상속을 받으셨다면 이 포스팅을 볼 필요없으십니다. 저랑 친구해요.

객체지향 프로그래밍에서는 부모 클래스의 맴버 변수와 메소드를 자식 클래스가 재사용하는 개념으로 알고 계시면 됩니다. 이 맴버 변수나 메소드에 접근 제한자를 더하여 아무나 접근할 수 없도록 할 수 있습니다. C++에서 상속하는 방법은 ' : ' 콜론을 사용하여 상속을 받을 수 있습니다. JAVA에서 extends를 사용하여 상속을 하는 것과 같습니다. 형식은 아래와 같습니다.

class 클래스_이름 : 접근제한자 부모_클래스명{
	//.. 내용 ..//
}

 

접근 제한자

접근 제한자는 세가지가 있습니다. private, protected, public이라는 접근 제한자가 있지요. 

접근 제한자 설명
private 오직 자신의 클래스안에서만 접근 가능한 멤버 변수, 메소드.
protected 자신을 상속하는 클래스까지만 접근 가능
public 어떤 클래스에서나 접근 가능

접근 범위

 

생성자 (Constructor)

클래스가 객체를 생성할때 항상 호출하는 일종의 메소드가 있습니다. 이 메소드는 생성자라는 이름을 갖고 있습니다. 여러분이 객체를 생성할때 초기화하는 방법을 생성자에 기재합니다. 이를 테면, 각 맴버 변수들의 초기화 등이 있지요. 형식은 아래와 같습니다. 자신의 클래스명과 동일하지만 반환이 없는 메소드가 바로 생성자입니다.  생성자를 사용하려면 반드시 public으로 해야합니다. 모든 곳에서 접근 가능해야 초기화를 진행할 수 있기 때문이죠. 

여러분들이 굳이 생성자를 정의해놓지 않아도 C++은 알아서 기본 생성자(Default Constructor) 만들어줍니다. 비록 아무런 동작을 하지 않는 껍데기에 불과하지만요.

class 클래스명 {
public :
	클래스명(파라미터){
    	//..동작 ..//
    }
}

 

소멸자(Destructor)

소멸자는 객체가 소멸할때 마지막 최종작업을 기록하는 메소드를 말하며 아래 형식과 같습니다. 소멸자는 반환값, 전달받는 인자가 없고 ~클래스이름() 형태여야합니다. 주로 free, delete 등의 메모리 누수가 발생하지 않게 메모리 해제 작업등이 여기에 포함됩니다.

class A{
public:
	~A(){
    	//..메모리 해제등의 작업 ..//
    }
}

 

 

 

이제 여기서 간단한 예를 들어보도록 하겠습니다. 

#include <iostream>
using namespace std;

class A {
private:
	int a;
	int b;
public:
	A(int _a, int _b){
		a = _a;
		b = _b;
	}
	int add() {
		return a + b;
	}
};

class B : A {
public :
	B(int _a, int _b) : A(_a, _b) {}
	void printResult() {
		//A를 상속받았으니, 부모클래스의 add라는 메소드를 사용할 수 있음.
		printf("%d\n", add());
	}
};

int main() {
	B b(1, 2);
	b.printResult();
}

 

A는 자신만이 접근한 맴버 변수 a와 b를 가지고 있습니다. 그리고 생성자를 갖고 있는데, 여기서 a와 b를 초기화시켜줍니다. 그리고 누구나 접근 가능한(public) 메소드 add를 갖고 있습니다. 이 메소드는 단순 a와 b를 더한 값을 전달하지요. 맴버를 초기화할 수 있는 방법은 이런 방법도 있습니다. 

public :
	A(int _a, int _b):a(_a),b(_b) {
		
	}

B는 A 클래스를 부모 클래스(또는 Super Class라고 합니다.)로 상속을 받습니다. 이때 부모 클래스의 초기화를 하는 방법은 : 부모클래스명(매개변수명1, 매개변수명2 ...)으로 초기화 시킬 수 있습니다. 자, 여기서 눈여겨 보셔야할 점은 B는 A클래스를 상속받았기 때문에 a와 b는 갖고 있지만, 접근할 수 없습니다. 

printResult라는 메소드에서는 add()라는 메소드를 사용할 수 있는데 A의 클래스에서 정의하고 public으로 접근할 수 있게 만들었기 때문에 B클래스에서 사용할 수 있습니다.

접근 제한자를 통한 상속

우리는 위의 예제에서 접근 제한자는 따로 지정하지 않고 상속을 받았습니다. 상속받는 부분에서 접근 제한자를 지정하게 되면 부모 클래스의 맴버 변수, 메소드를 지정한 접근 제한자보다 범위가 넓은 맴버 변수, 메소드에 접근할 수가 없습니다. public을 명시적으로 지정해주어야만 다형성의 속성을 사용할 수 있습니다.

private 상속 

#include <iostream>
using namespace std;

class A {
private:
	int a;
protected:
	int b;
public:
	int c;
};

class B : private A { //b,c맴버 변수는 private 맴버로 접근 범위 졻혀짐

};


int main() {
	B b;
    //a = private, b = private, c = private
	b.a;
	b.b;
	b.c;
}

 

 

 

B는 A를 private로 상속받습니다. A의 맴버변수 b,c는 private보다 범위가 넓으므로 b,c는 private로 제한합니다. 따라서 a,b,c 모두 접근할 수 없습니다.

protected 상속

#include <iostream>
using namespace std;

class A {
private:
	int a;
protected:
	int b;
public:
	int c;
};

class B : protected A { //c맴버 변수는 protected 맴버로 접근 범위 졻혀짐

};


int main() {
	B b;

	//a = private, b = protected, c = protected
	b.a;
	b.b;
	b.c;
}

A의 맴버 변수 중에 protected보다 범위가 넓은 맴버는 protected로 접근이 제한됩니다.

public 상속

#include <iostream>
using namespace std;

class A {
private:
	int a;
protected:
	int b;
public:
	int c;
};

class B : public A { //맴버 변수의 접근 제한에 변화없음

};


int main() {
	B b;

	//a = private, b = protected, c = public
	b.a;
	b.b;
	b.c;
}

public보다 접근 제한이 넓은 맴버는 public으로 맞춰지는데, 의미없죠? public보다 큰 제한자는 없으니까요. 

 

캡슐화(Encapsulation)

캡슐화라는 것은 맴버 변수를 직접 변경할 수 없도록 캡슐처럼 껍데기를 둘러싸는 과정을 말합니다. 캡슐안에서 특정 로직에 따라 맴버 변수가 적절하게 변경되어야 프로그램이 안전할 수 있기 때문이죠. 아래의 코드가 그 예를 보여줍니다.

#include <iostream>
using namespace std;

class A {
private:
	int a;
	int b;
public:
	void setA(int _a) {
		if (_a > 50)
			_a = 50;
		a = _a;
	}
	void setB(int _b) {
		if (_b > 100)
			_b = 100;
		b = _b;
	}

	int getA() {
		return a;
	}
	int getB() {
		return b;
	}
};

class B : A {
public :
	void setAB(int a,int b) {
		setA(a);
		setB(b);
	}
	void printResult() {
		printf("%d + %d = %d\n", getA(), getB(), getA() + getB());
	}
};


int main() {
	B b;
	b.setAB(100, 200);
	b.printResult();
}

 

간단한 코드인데요. B는 A를 상속받습니다. 이때 a,b는 직접 설정할 수 없게 private로 접근을 제한했구요. 메인 함수에서는 b에서 setAB를 호출해서 a의 값을 100, b의 값을 200으로 바꾸려고 합니다. 하지만 A 클래스는 그것을 용납하지 않습니다. a의 최대값은 50, b의 최대값은 100으로 제한을 해놓았기 때문이죠. B가 a와 b를 변경할 수 있는 수단은 A 클래스의 setA와 setB를 호출하여 인자 전달을 하는 방법밖에 없습니다. 이렇게 맴버의 값을 함부로 변경할 수 없도록 맴버 함수로 껍질을 입히는 작업을 캡슐화라고 합니다. 이렇게 Get, Set 메소드를 Getter, Setter메소드라고 합니다.

객체지향 언어에서 반드시 필요한 속성임을 기억하세요.

 

 

반응형
블로그 이미지

REAKWON

와나진짜

,

 

링크에 대한 성질, 그리고 이에 대한 더 많은 정보와 예제를 담은 리눅스 교재를 배포했습니다. 아래의 페이지에서 리눅스 교재를 받아가세요.

https://reakwon.tistory.com/233

 

리눅스 프로그래밍 note 배포

티스토리에 리눅스에 관한 내용을 두서없이 여지껏 포스팅했었데요. 저도 제 포스팅을 찾기가 어렵기도 하고 티스토리에서 코드삽입을 하게 되면 이게 일자로 쭉 쓰여져있는 x같은 현상이 생겨

reakwon.tistory.com

 

링크

파일은 서로 참조하고 참조 받을 수 있습니다. 그러니까 어떤 파일이 어떤 파일을 삿대질해서 가리킬 수도 있고(무척 평화적이죠? 현실세계에서는 참교육 시전받을 수 있습니다.) 같은 내용을 바라볼 수도 있다는 뜻입니다. 이러한 성질을 링크라고 합니다. 링크를 본격적으로 설명하기 전에 I-node라는 개념을 먼저 알아야 할 팔요가 있습니다.

1. Inode

Inode는 아래에 보는바와 같이 i-node라는 구조체를 가리키게 됩니다. 이 i-node에는 파일에 대한 거의 모든 정보(파일 모드, 링크개수, UID, GID, 파일 크기, 파일 관련 시간 등)가 담겨 있는데, 내용은 담겨있지 않습니다. 파일의 메타정보라고 생각하시면 됩니다. i-node는 파일의 내용을 가리키는 일종의 포인터를 갖고 있을 뿐이죠. Single indirect, Double indirect,  Triple indirect 등이 보이죠? 이게 다 파일에 대한 내용을 가리키는 블록들이며, 마치 C언어에서 포인터, 더블 포인터 등과 같이 내용을 가리키게 됩니다.

출처 : https://www.systranbox.com/exploring-the-essential-role-of-inodes-in-linux-and-unix-operating-systems/

inode가 갖고 있는 정보는 아래와 같으니 참고하시기 바래요.

inode가 가지고 있는 정보 설명
inode 번호 inode의 고유 식별 번호입니다.
파일 모드 16비트의 플래그로 파일의 실행 권한입니다. 소유자의 권한, 소유자 그룹의 권한, 기타 사용자의 권한, 파일 형식, 실행 플래그 등을 나타냅니다.
링크 수 이 아이노드에 대한 참조 수를 나타냅니다.
소유자 아이디 파일의 소유자 아이디를 나타냅니다.
그룹 아이디 파일 소유자의 그룹 아아디를 나타냅니다.
파일 크기 파일의 크기(bytes)를 나타냅니다.
파일 주소 실 데이터가 나오는 파일 주소를 나타냅니다.
마지막 접근 마지막으로 파일에 접근한 시간을 나타냅니다.
마지막 수정 마지막으로 파일을 수정한 시간을 나타냅니다.
아이노드 수정 마지막으로 아이노드를 수정한 시간을 의미합니다.

I-node는 각자 자신들의 고유한 번호를 가지고 있는데, 이 고유한 번호를 i-node 번호라고 합니다. ls 명령어에서 -i 옵션은 i-node번호를 같이 보여줍니다. 그래서 i-node번호와 같이 자세한 출력은 원하면 ls -li 을 입력하면 됩니다. 왼쪽에 나오는 숫자가 바로 i-node 번호입니다.

# ls -il /usr/lib
total 2952
6291630 drwxr-xr-x 105 root root   86016 Jun  8 06:17 aarch64-linux-gnu
6441489 drwxr-xr-x   2 root root    4096 Apr 18 00:48 apg
6291631 drwxr-xr-x   2 root root    4096 Feb 17 17:30 apparmor
6291632 drwxr-xr-x   5 root root    4096 Feb 17 17:30 apt
6442507 drwxr-xr-x   3 root root   12288 Apr 18 00:48 aspell

실제 inode의 내용을 확인해보고 싶다면 stat 명령을 사용해서 확인해보시기 바랍니다. stat명령도 내부적으로 inode에서 정보를 얻는 구현이 있기 때문입니다. 아래는 단순 빈 파일의 정보입니다.

# stat /etc/passwd
  File: /etc/passwd
  Size: 3212            Blocks: 8          IO Block: 4096   regular file
Device: fd00h/64768d    Inode: 5245840     Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2023-08-14 09:04:09.872000001 +0000
Modify: 2023-07-27 17:40:33.552013428 +0000
Change: 2023-07-27 17:40:33.560013428 +0000
 Birth: 2023-07-27 17:40:33.552013428 +0000

같은 I-node를 가리키느냐, 아니면 데이터 블록이 원본 파일을 가리키느냐에 따라서 하드링크(Hard link)와 심볼릭(Symbolic link)로 나뉘어집니다. 파일에 링크를 걸어주는 명령이 ln 명령어입니다. ln 명령어를 통해서 하드링크와 심볼릭 링크가 무엇인지 확인해봅시다.

       ln [OPTION]... [-T] TARGET LINK_NAME
       ln [OPTION]... TARGET
       ln [OPTION]... TARGET... DIRECTORY
       ln [OPTION]... -t DIRECTORY TARGET...

 

2. 하드링크

하드링크는 파일이 같은 i-node를 가리켜 파일의 내용을 공유하는 링크 방식을 의미합니다. 하드 링크는 같은 i-node를 가리키기 때문에 i-node 번호가 같습니다. ln 명령어로 하드링크를 걸어봅시다. 하드 링크를 거는 방법은 아래와 같은 명령어 형식을 사용하면 됩니다. 

ln 원본파일 링크파일

우선 아래와 같이 문자열을 담고 있는 origin이라는 파일을 하나 생성하고, i-node 번호(1열)를 확인하면 464235라는 것을 확인할 수 있네요. 파일을 최초로 만들었을 땐 링크수(3열)가 1이라는 점! 

# echo "hard link test" > origin
# ls -il
total 4
464235 -----w--w- 1 root ubuntu 15 Jun 14 01:35 origin

이후 ln 명령어를 통해서 하드링크를 걸어줍니다. I-node와 링크수를 확인해보니, i-node는 464235로 같고 링크수가 2로 하나 증가함을 알 수 있습니다. 그 외의 파일의 권한, 소유자, 시간 모두 정확히 같습니다. 그렇죠?

# ln origin  hardlink
# ls -il
total 8
464235 -----w--w- 2 root ubuntu 15 Jun 14 01:35 hardlink
464235 -----w--w- 2 root ubuntu 15 Jun 14 01:35 origin

그렇다면 우리는 이런 그림을 그려볼 수 있습니다. 링크수는 i-node를 가리키는 화살표의 갯수를 의미하며, 두 파일은 i-node를 같이 공유하고 있음을 알 수 있습니다.

2.1 하드링크의 원본 파일 삭제 시

그렇다면 만약 어느 하나의 파일을 삭제하게 된다면 어떻게 될까요? 여기서는 원본의 파일을 삭제해보도록 하겠습니다. 

# rm origin 
# ls -il
total 4
464235 -----w--w- 1 root ubuntu 15 Jun 14 01:35 hardlink
# cat hardlink 
hard link test

결과에서 알 수 있듯이 i-node의 번호는 변하지 않고, 링크의 갯수가 하나 줄었습니다. 내용을 보면 심지어 내용도 보존이 됩니다. 원본 파일을 삭제했는데도 말이죠. 이런 상황입니다. 

그래서 파일을 삭제한다는 뜻은 링크를 제거한다는 것과 유사합니다. 

 

3. 심볼릭 링크(Symbolic Link)

심볼릭 링크는 ln 명령어에 -s 옵션을 추가하여 링크를 걸 수 있습니다. 위와 같은 과정으로 테스트를 해보면 하드링크와 다른점을 확인할 수 있습니다.

# echo "symbolic link test" > origin
# ls -il
total 4
464235 -----w--w- 1 root ubuntu 19 Jun 14 01:54 origin
# ln -s origin symlink
# ls -il
total 4
464235 -----w--w- 1 root ubuntu 19 Jun 14 01:54 origin
464240 lrwxrwxrwx 1 root ubuntu  6 Jun 14 01:55 symlink -> origin

하드링크와의 차이점을 정리해보면 이렇습니다.

  • origin의 i-node 번호(1열)와 symlink 파일의 i-node번호가 다르다.
  • 링크의 갯수(3열)가 변하지 않는다.
  • 다른 기타 정보(권한, 크기, 시간 정보 등)이 다르다.
  • 파일의 유형을 보면 정규 파일('-')이 아니라 링크('l')이다.

이 상태에서 원본 파일과 심볼릭 링크 파일의 관계를 그리면요. 아래와 같습니다. 

이 그림이 성립이 된다는 것을 확인시켜드리고자 이 상태에서 원본 파일을 과감하게 지우겠습니다. 

# rm origin 
# ls -il
total 0
464240 lrwxrwxrwx 1 root ubuntu 6 Jun 14 01:55 symlink -> origin
# cat symlink 
cat: symlink: No such file or directory

원본 파일인 origin을 삭제하고 심볼릭 링크를 읽으려해보면 그러한 파일을 찾을 수 없다는 에러 메시지를 볼 수 있습니다.  만약 다시 origin을 생성해보면 어떨까요?

# echo "new origin" > origin
# ls -il
total 4
464235 -----w--w- 1 root ubuntu 11 Jun 14 06:59 origin
464240 lrwxrwxrwx 1 root ubuntu  6 Jun 14 01:55 symlink -> origin
# cat symlink 
new origin

이러한 경우에는 다시 symlink로 파일을 읽을 수 있습니다. 다만 그 내용은 원래 이전의 origin의 내용이 아니라 다시 생성한 origin 파일의 내용인 것을 알 수 있습니다. 결국은 심볼릭 링크의 경우에 파일을 가리킨다는 것을 확인할 수 있습니다. 

4. ln 명령어 구현 

간단한 ln 명령어를 구현해보도록 하겠습니다. 그전에 우리가 알아야할 관현 함수들은 symlink와 linkat 함수입니다. 

  • symlink
#include <unistd.h>
int symlink(const char *target, const char *linkpath);

심볼릭 링크를 거는 시스템 콜입니다. target은 현재 프로세스 위치의 파일 이름입니다. 상대 경로일 수 있습니다. linkpath는 링크 파일의 이름입니다. 이때 이 파일이 존재하면 에러 -1이 발생합니다. 

  • linkat
#include <unistd.h>
int linkat(int olddirfd, const char *oldpath,
			int newdirfd, const char *newpath, int flags);

하드 링크, 심볼릭 링크 둘 다 걸 수 있는 시스템 콜입니다. linkat 함수는 조금 까다로울 수 있는데, newpath가 oldpath를 가리키도록 링크를 걸게 됩니다.

olddirfd와 oldpath, newdirfd와 newpath가 한 쌍으로 파일을 나타냅니다. oldpath가 절대 경로일 경우 olddirfd는 무시가 되고, oldpath가 상대 경로일 경우 olddirfd 기준의 상대경로가 됩니다. newdirfd와 newpath와의 관계도 마찬가지입니다. 함수를 호출한 프로세스를 기준으로 상대위치로 만들고 싶다면 fd인자에 AT_FDCWD 상수를 사용해야합니다.

flag는 AT_SYMLINK_FOLLOW를 사용할 수 있습니다. 만약 이 flag를 쓰게 되면 원본 파일이 심볼릭 링크라면 링크를 따라가서 하드 링크를 겁니다. 원본 파일이라니까 헷갈리실거 같은데 ln 명령어에서 첫번째 인수를 말해요. 이 flag를 쓰지 않으면 원본 파일인 심볼릭 링크를 따라가 심볼릭 링크를 겁니다. 

ln 원본파일 링크파일

즉, 정리하면

  • AT_SYMLINK_FOLLOW 를 사용할 경우 : symlink를 계속 따라가서 harlink
  • AT_SYMLINK_FOLLOW를 사용하지 않은 경우 : symlink를 계속 따라가서 symlink

 

4.1 소스코드 

이제 간단히 구현한 myln.c 코드를 보겠습니다.

//myln.c

#include <unistd.h>
#include <stdbool.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

// -s 옵션 
bool symbolic = false;
// -L 옵션
bool logical = false;

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

        int c;
        char **files_name;
        char *src, *dst;

        //옵션 파싱
        while((c = getopt(argc, argv, "sL")) != -1 ){
                switch(c){
                        case 's':
                                symbolic = true;
                                break;
                        case 'L':
                                logical = true;
                                break;
                }
        }

        files_name = argv + optind;

        //원본 파일
        src = files_name[0];
        //링크 파일
        dst = files_name[1];

        //심볼릭 링크시에 symlink 사용
        if(symbolic){
                return symlink(src, dst);
        }

        //심볼릭 링크가 아니면 -L 옵션 여부에 따라 AT_SYMLINK_FOLLOW 플래그 설정
        return linkat(AT_FDCWD, src, AT_FDCWD, dst, logical ? AT_SYMLINK_FOLLOW : 0);

}

linkat에 AT_FDCWD를 사용한 것을 보시기 바랍니다. AT_FDCWD는 현재 싱행되는 프로그램의 위치를 기준삼아 src를 찾고, dst링크 파일을 만듭니다. 위 코드는 실제 ln의 코드를 풀어 쓴 코드입니다. 아래는 실제 ln의 소스 코드 일부분입니다.

static bool
do_link (const char *source, const char *dest)
{
 ...
   ok = ((symbolic_link ? symlink (source, dest)
         : linkat (AT_FDCWD, source, AT_FDCWD, dest,
                   logical ? AT_SYMLINK_FOLLOW : 0))
        == 0);
 
 ...
 }

 

이제 코드 결과를 확인해봅시다. 

# gcc myln.c 
# echo "this is origin" > origin
# ./a.out -s origin symlink1
# ./a.out origin hardlink1
# ls -il
total 24
464557 -rwxr-xr-x 1 root root 9088 Aug 15 05:59 a.out
464558 -rw-r--r-- 2 root root   15 Aug 15 05:59 hardlink1
464648 -rw-r--r-- 1 root root 1121 Aug 15 05:52 myln.c
464558 -rw-r--r-- 2 root root   15 Aug 15 05:59 origin
464646 lrwxrwxrwx 1 root root    6 Aug 15 05:59 symlink1 -> origin

우선 원본 파일인 origin을 생성하고 심볼릭 링크와 하드링크를 하나씩 걸었습니다. symlink1은 심볼릭 링크로 잘 링크되어있고, hardlink1도 inode를 보니 origin과 같아서 하드링크가 잘 걸려있네요!

-L옵션도 확인해볼까요? 

# ./a.out -L symlink1 hardlink2
# ./a.out symlink1 symlink2
# ls -il
total 28
464557 -rwxr-xr-x 1 root root 9088 Aug 15 05:59 a.out
464558 -rw-r--r-- 3 root root   15 Aug 15 05:59 hardlink1
464558 -rw-r--r-- 3 root root   15 Aug 15 05:59 hardlink2
464648 -rw-r--r-- 1 root root 1121 Aug 15 05:52 myln.c
464558 -rw-r--r-- 3 root root   15 Aug 15 05:59 origin
464646 lrwxrwxrwx 2 root root    6 Aug 15 05:59 symlink1 -> origin
464646 lrwxrwxrwx 2 root root    6 Aug 15 05:59 symlink2 -> origin

-L옵션은 linkat 콜 인자 중 flag에 AT_SYMLINK_FOLLOW를 전달한 것과 같습니다. 그러니까 -L 옵션을 줄 경우 원본 파일은 symlink1을 따라가서 최종 원본 파일을 찾고 거기에 하드링크를 걸게 됩니다. -L 옵션을 뺀 경우, 그러니까 AT_SYMLINK_FOLLOW옵션을 주지 않았을 경우 링크를 계속 따라가서 최종 원본 파일에 심볼릭 링크를 걸게 되지요. 

여기까지 리눅스의 i-node가 대한 간단한 소개와 link의 방식인 hard link, symbolic link의 개념과 차이점을 확인해보았으며 직접 ln 명령어를 맛보기로 구현해보았습니다.

반응형
블로그 이미지

REAKWON

와나진짜

,

 

 

DNS(Domain Name System)

우리들이 www.naver.com 이나 www.google.com 를 웹브라우저를 통해 접속할때 문자열로 된 주소를 타이핑하여서 웹 사이트에 접속을 하게 되죠? 근데 컴퓨터는 글자로 된 주소는 사실 모르고, 서버 컴퓨터의 IP를 이용해서 웹사이트에 접속하게 됩니다. naver에 접속을 하면 위의 www로 시작하는 주소를 입력하여 접속해도 되지만 이번에는 IP주소를 직접 쳐서 들어가보세요. IP를 알아볼 수 있는 명령어는 nslookup입니다. 저는 naver와 nate의 주소를 알아보았습니다.

nslookup - naver
nslookup - nate

223.130.195.200이라는 IP주소를 웹브라우저에 쳐서 들어가도 똑같이 네이버에 접속이 가능합니다. 네이트의 주소는 120.50.131.112네요.

whois 명령을 사용하면 도메인의 등록 정보를 알아볼 수 있습니다. 단, 업체에서 whois서버가 따로 존재해야합니다. 사용 방법은 whois 도메인명 혹인 whois IP주소 를 쳐서 도메인 등록 정보를 알 수 있습니다. google도메인에 대한 정보를 알아봤습니다. whois google을 타이핑해보세요.

whois google

 

아무튼 그렇다면 컴퓨터가 어떻게 이 주소를 알수 있을까요? 컴퓨터는 다음의 순서로 알아냅니다. 

1. 로컬 DNS Cache에서 IP 검색

2. hosts파일에서 IP 검색

3. DNS 서버에 질의

로컬에 저장된 캐시에 해당 IP주소가 있는지 확인하고 그래도 없으면 hosts파일에서 IP를 찾아옵니다. (참고로 DNS Cache를 삭제하려면 /etc/init.d/dns-clean restart 명령을 사용하여 삭제하면 됩니다.)
이 DNS 캐시에도 없으면 DNS서버에 물어보게 되죠. hosts파일은 무엇일까요? 리눅스에서 /etc/hosts파일을 아래와 같이 수정해봅시다. 네이버의 주소를 네이트의 IP주소를, 네이트의 주소를 네이트의 IP주소로 저장해 놓으면 www.naver.com  접속시 네이트가 접속이 됩니다. 물론 악의적인 사람은 이 파일을 악용하는 사람들도 있겠죠?

 

 

hosts파일에도 맞는 IP주소가 없으면 이제 서버에 여쭈어보게 됩니다. 이 서버 불쌍한 놈입니다.. 우리가 알려달라고 하면 여기 저기 물어보고 그제서야 알려줍니다.

dns 질의

사용자의 컴퓨터는 google의 주소를 서버에 물어봅니다. 우리가 www.google.com이라고 치면 사실 www.google.com. 으로 되어 접속이 됩니다. 여기 맨 끝에 끝나는 . 을 루트 도메인이라고 부릅니다. 자, 우리가 직접 물어본 서버는 이제 최상위의 Root DNS서버(.)에게 google IP에 대해 질의합니다. Root DNS 서버는 다음 도메인(.com)의 서버를 알려줍니다. 이제 우리 서버는 COM 서버에 질의합니다. 이 불친절한 com 서버도 역시 google.com의 서버에게 질의하라고 넘깁니다. 이제 google.com 서버에 질의합니다. google.com은 www.google.com의 IP주소를 를 알려줍니다. 이제 우리의 서버는 IP주소를 사용자에게 알려주지요. 이렇게 주소를 받은 사용자 컴퓨터는 다음에 또 물어보기 미안하기 때문에 DNS Cache에 일정시간 동안 저장합니다.

이때 사용자가 질의하는 방식을 재귀적 질의(Recursive Query)라고 하며, DNS서버가 다른 DNS서버에게 질의하는 방식을 반복적 질의(Iterative Query)라고 합니다. 우리가 Recursieve Query를 날리는 서버는 보통 인터넷 서비스 제공 업체의 서버 주소입니다. Root DNS 서버나 Com DNS 서버를 Authoritative DNS 서버라고 합니다.

DNS서버의 IP주소는 어디에 기록되어 있을까요? 리눅스에서 /etc/resolv.conf 가 DNS 서버의 주소를 갖고 있습니다. 

/etc/resolv.conf

 

DNS는 보통 UDP 53번 포트를 사용합니다. TCP 53번도 쓰는 경우가 있는데, 이것은 DNS 헤더의 특수한 flag를 설정하면 TCP로 DNS 연결을 할 수 있고, 응답하는 데이터가 512 바이트를 넘어갈 경우 TCP를 통해 응답합니다.

실제 DNS 헤더는 어떤 정보를 담고 있을까요? 다음은 저의 DNS 패킷 중 하나입니다. 제가 사용하는 DNS서버의 주소는 210.220.163.82 인것을 알 수 있습니다. 여기에 Transaction ID와 Flag, 그리고 가장 밑에 질의(Query)를 볼 수 있습니다. 질의 형태(type)은 주로 A인데요. DNS의 본질인 IP주소를 받아오는 질의 타입이 A입니다.

 

 

DNS Query 요청

그리고 그에 대한 응답입니다. 응답에서 Transaction ID를 보면 이전에 요청했던 Transaction ID와 같은 것을 볼 수 있습니다. 그리고 우리가 알고 싶은 IP주소는 Answer필드에 있는 것을 볼 수 있네요.

DNS response

 

DNS의 패킷 그대로를 살펴보면 가장 마지막 4바이트가 우리가 원하는 주소라는 것을 알 수 있습니다. AC D9 A1 EE(172 217 161 238)이 주소이지요.

DNS raw packet

 

DNS의 질의는 아래 표에 정리되고 있습니다.

질의 TYPE 설명
A 도메인에 대한 IP 정보를 담고 있습니다.
ANY 도메인에 대한 모든 정보를 질의합니다.
MX 메일 서버에 대해 질의합니다.
NS Name Server에 대한 질의를 합니다.
SOA zone파일의 SOA 에 대해 질의합니다.
HINFO Host에 대한 정보의 질의를 합니다.
PTR Reverse Zone 파일에 설정된 PTR 레코드를 질의합니다. 
CNAME 호스트의 Alias 정보에 대한 질의입니다.

 

이상으로 DNS에 대한 개념과 실 데이터를 구경해보았습니다. 

 

 

반응형
블로그 이미지

REAKWON

와나진짜

,

 

 

위상 정렬(Topological Sort)

위상 정렬은 무엇일까요? 여러 작업들이 있을때 특정 작업을 수행하기 전 진행되어야 할 작업들이 있을텐데요. 이 작업들을 순서에 맞게 정렬해주는 것이 바로 위상정렬이라고 합니다. 이때 각 작업들은 직전 작업에 의존하게 되죠. 예를 들어 C라는 작업을 끝내기 위해서는 A작업과 B작업이 끝나야지만 C작업을 시작할 수 있는데, C는 A와 B가 끝날때까지 일을 실행하지 못하므로 A와 B에게 의존하고 있다고 봐야겠죠.

위상정렬은 우리 삶에도 영향이 있습니다. 예를 들어 요리할때나 청소할때, 여러분이 좋아하는 게임을 할때도 일의 순서등 말이죠.

위상정렬은 깊이 우선 탐색(Depth First Search, DFS)로 풀 수 있는 대표적인 정렬 방법입니다. 여기서 그래프는 의존성 그래프(Dependency Graph)의 모양을 띄고 있어야하는데, 그 말은 각 정점(작업)의 의존 관계를 간선으로 나타낸 방향 그래프라는 의미입니다. 만일 작업 v는 u가 끝나야만 수행할 수 있다면(v가 u에 의존한다면), 그래프는 u->v로 향하는 간선을 포함하게 됩니다. 의존성 그래프에서는 사이클이 존재할 수 없습니다.

자, 여기 작업을 나타내는 의존성 그래프가 있습니다. 

dependency graph

 

위상정렬 순서 단계별 알아보기

작업 순서)

B와 C는 A가 작업을 끝나야만 실행 가능

D는 B와 C가 작업을 끝나야만 실행 가능

E는 C가 끝나야만 실행 가능

F는 D와 E가 작업을 끝나야만 실행 가능

만약 여기서 F가 A로 향하는 간선이 있다면 어떻게 될까요? 그렇다는 의미는 A는 F 다음으로 수행가능 하다는 것인데, A는 이미 수행했기 때문에 모순입니다. 그렇기 때문에 의존성 그래프에는 사이클이 발생하지 않습니다. 이 그래프를 보기좋게 옆으로 돌려보도록 하겠습니다. 그러면 아래의 모양이 나오겠네요.

dependency graph2

 

DFS를 이용해서 이 그래프의 위상정렬을 하면 결과는 이렇습니다. A C E B D F

 

 

여기서 힌트는 DFS를 종료한 역순이라는 것이 위상정렬된 결과라는 점입니다. 글로는 이해가 어려울 수 있으니, 이제 DFS를 통해서 어떻게 이 그래프가 위상 정렬이 되는지 그 과정을 보도록 합시다. 그전에 DFS를 방문할 노드의 규칙을 다음과 같이 정합시다. 연결된 정점 중 알파벳 순서가 가장 낮은 정점부터 탐색하는 것으로요.

 1. 들어오는 간선이 없는 A부터 DFS 탐색을 실시하면 아래의 그림과 같이 F에서 끝이 납니다. F는 이때 더 이상 나가는 간선이 없으니까 F는 종료가 됩니다. - 종료 순서 F

 

f finish

 2. F가 반환되면 D가 다음 종료됩니다. 여기서 D에서 더 이상 방문할 정점이 없으니까요. - 종료 순서 F D

D finish

 

3. B역시 마찬가지로 종료됩니다. - 종료 순서 F D B

 

4. 이후 A는 종료되지 않습니다. 왜냐면 C와 연결된 정점이 있으니까요. 그래서 결국 E까지 DFS가 탐색을 하여 도달하게 됩니다. E는 더이상 방문할 노드가 없습니다. 그러니 종료하게 됩니다. - 종료 순서 F D B E

E finish

5. C는 D와 연결된 간선이 있는데 이전에 D는 방문 됐었죠? 그러니 C도 더 이상 방문할 정점이 없으니 종료됩니다. - 종료 순서 F D B E C

C finish

 

6. 이제 A는 더 이상 방문할 노드가 없으니 종료됩니다. 종료 순서 F D B E C A

A finish

 

DFS가 종료된 순서는 F D B E C A 인데, 이 순서를 역으로 취하면 A C E B D F 입니다. 어때요? 우리가 원하는 결과를 얻을 수 있죠? 

 

 

그리고 아래의 코드는 위상정렬을 구현한 코드입니다. 

#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
int N,M;
vector<vector<int> > adj;


vector<bool> visited;
vector<int> order;
void dfs(int here) {
	visited[here] = true;
	
	for (int there = 0; there < adj.size(); there++) {
		if (adj[here][there] && !visited[there])
			dfs(there);
	}
	//위의 정점이 다 종료된 후에 이 곳의 정점(here)이 추가가 되어야함
	order.push_back(here);
}


void topologicalSort() {
	int n = adj.size();
	visited = vector<bool>(N, false);

	order.clear();

	//들어오는 간선이 없을 경우가 있을 수 있으므로 모두 DFS 탐색
	for (int i = 0;i<N;i++)
		if (!visited[i]) dfs(i);

	//종료된 순서를 거꾸로 만든다.
	reverse(order.begin(), order.end());
}

void printOrder() {
	for (int i = 0; i < order.size(); i++)
		printf("%c ", order[i] + 'A');
	printf("\n");
}
int main() {
	printf("정점의 갯수 : ");
	scanf("%d", &N);
	
	printf("간선의 갯수 : ");
	scanf("%d", &M);

	visited = vector<bool>(N, false);
	adj = vector<vector<int>>(N, vector<int>(N, 0));

	for (int i = 0; i < M; i++) {
		char from, to;
		printf("정점1 -> 정점2 : ");
		scanf(" %c %c", &from, &to);
		adj[from-'A'][to-'A'] = 1;
	}
	
	topologicalSort();
	printOrder();
}

 

topologicalSort안에 dfs를 전부 호출하는 이유는 아래의 그림과 같은 정점이 있을 수 있기 때문입니다. 여기서 G는 A와 같이 들어오는 간선(inboud-edge)이 없으므로 A부터 수행한 DFS에서는 G정점이 방문되지 않습니다. 그렇기 때문에 모든 정점을 방문하는 것입니다.

dfs를 전부 수행하는 이유

 

위의 코드를 실행한 결과를 보면 위상정렬이 잘 된 것임을 확인할 수 있습니다.

첫번째 그래프의 결과

 

그리고 제가 제시했던 바로 위의 그래프의 위상정렬 결과는 아래와 같습니다. 

두번째 그래프의 결과

 

이상으로 위상정렬에 대해서 알아보았습니다. DFS의 응용이면서 결코 어렵지 않은 주제니까 원리와 코드는 알아두시면 좋겠습니다. 위의 코드는 구종만 저자님의 알고리즘 문제 해결 전략을 변형해서 만든 코드입니다.

 

 

반응형
블로그 이미지

REAKWON

와나진짜

,

 

 

ARP(Address Resolution Protocol)

ARP는 논리적인 주소(Logical Address)를 물리적 주소(Physical Address)로 변환하는 프로토콜을 의미합니다. 논리적인 주소라함은 IP주소, 물리적 주소는 MAC 주소(하드웨어 주소)라고 생각하면 훨씬 편합니다. IP주소는 어떻게 생겼죠?

예를 들어 저의 IP는 192.168.35.10이라고 해봅시다. 그리고 저희 MAC주소는 AA:BB:CC:DD:EE:FF라고 해볼게요. 저의 IP주소는 저 말고도 다른 사람도 쓸 수 있습니다. 왜냐면 네트워크는 특정 구역마다 나누어져 있으니까요. 쉽게 라우터를 기준으로 나누어져 있다고 생각하시면 됩니다. 그래서 논리적인 주소라 칭합니다. 저의 MAC주소는 이 세상에 단 하나밖에 없습니다. 하드웨어 고유한 주소이기 때문에 물리적 주소라고 합니다.

같은 네트워크에서는 IP주소로 통신하지 않고 물리적 주소를 가지고 통신합니다. 즉, MAC주소를 사용하여 통신한다는 것이죠. 스위치가 왜 2계층에 동작하는 것이 그 이유입니다. 2계층에서 MAC주소만 보아 정보 전송을 하면 되니까요.

어쨌든, IP주소를 MAC주소로 변환하는 프로토콜이 ARP라고 하는 거죠. ARP의 동작 방식을 보도록 합시다. 

1. 초기 Host A는 192.168.30.6에 MAC주소를 모르니까 네트워크 전체에 192.168.30.6에 대한 MAC주소를 물어보는 요청을 보냅니다.

ARP Request

 

2. 이 IP주소에 맞는 다른 호스트는 자신의 MAC주소를 담아 응답을 보냅니다. 요청 메시지에 자신의 MAC주소를 요청한 호스트 A의 IP주소가 있기 때문에 A에게 응답을 곧장 보낼 수 있습니다. 

ARP Reply

 

3. MAC주소를 알아낸 호스트 A는 ARP의 정보를 담고 있는 저장소에 192.168.30.6의 MAC주소를 업데이트 합니다. 이 저장소를 ARP Cache라고 하며 일정 시간 후에 이 정보를 삭제합니다.

 

RARP(Reverse ARP)

반대로 MAC주소를 IP주소로 변환하는 프로토콜은 무엇일까요? ARP의 반대 과정을 거친다고 하여 RARP(Reverse ARP)라고 합니다. RARP 동작이 되려면 따로 RARP 서버가 존재하여야합니다. 자신의 IP를 모를때 이 프로토콜을 사용합니다. 동작방식은 아래의 순서와 같습니다.

1. 최초 호스트 A는 자신의 IP를 모르는 상태이기 때문에 IP를 요청하는 RARP Request를 Broadcasting으로 보냅니다. 이때 요청에는 자신의 MAC주소가 기재되어있습니다.

RARP Request

 

 

2. RARP 서버는 요청한 호스트 A의 IP 주소를 담은 RARP 응답 메시지를 만들어 Host A에게 전송합니다. 

RARP Reply

 

ARP Cache

실제 ARP cache는 어떻게 생겼을까요?  cmd 창을 열고 arp -a 명령을 처보면 아래와 같이 ARP Cache 내용을 볼 수 있습니다. 저는 가장 호스트까지 있기 때문에 인터페이스가 2개가 잡히는데, 아니면 하나만 보일 수 있습니다. 여기에 IP주소와 MAC주소가 보이시죠? 그리고 맨 오른쪽에 정적, 동적이 보이는데, 정적은 등록된 ARP정보가 수동으로 시스템 종료가 되기 전까지 영구적으로 설정이 된것이고 동적은 컴퓨터가 알아서 설정한 것이고 시간이 지나면 없어지게 됩니다.

arp -a

ARP Cache내용을 지우려면 arp -d를 사용하시면 됩니다. arp -d를 쳤을때 관리자 권한이 필요하다고 나온다면 cmd를 오른쪽 마우스를 클릭하면 관리자 권한으로 실행이라는 항목이 보이실 겁니다. 이것을 통해 cmd를 실행해보세요.

지워도 되냐구요? 단순히 컴퓨터를 쓰는 일반 사용자라면 지우셔도 됩니다. 다시 업데이트 될거니까요. 그 후 arp -a를 보면 반드시 필요한 몇개의 MAC주소를 제외하면 지워진것을 볼 수 있습니다. 

그리고 정적으로 MAC주소를 등록하려면 arp -s를 이용하면 됩니다. 아래와 같이 "arp -s IP주소 MAC주소" 를 입력하면 됩니다.

arp -s

 

 

ARP 스푸핑(ARP Spoofing)

혹은 ARP Cache Poisoning이라고 합니다. 공격자는 두 호스트의 통신을 감시하기 위해서 두 호스트의 ARP Cache를 변경하는 것을 공격방법입니다. 공격자는 아래의 그림처럼 지속적으로 ARP 응답을 보냅니다. 물론 잘못된 정보로 말이죠. 

 

 

만약 동적으로 ARP 정보가 등록되어있다면 시간이 지나면 삭제되는 것을 이용합니다. 그때 응답을 받으면 피해 컴퓨터는 잘못된 정보로 ARP Cache를 업데이트하게 됩니다. 여기서 AA:AA:AA:AA:AA:AA의 MAC주소를 갖는 호스트를 Alice, BB:BB:BB:BB:BB:BB의 MAC주소를 갖는 호스트를 Bob라고 부르도록 하겠습니다. 공격자 자신은 정상적인 ARP Cache를 가지고 있습니다.

이때 Alice가 Bob에게 데이터를 전송할때 C에게 그 정보를 전송합니다. 맨 처음에 같은 네트워크는 MAC주소만 보고 그 데이터를 전달한다고 했죠? 그러니 공격자의 MAC주소인 CC:CC:CC:CC:CC:CC으로 전달하게 되죠. 공격자는 이제 Alice의 데이터를 볼 수 있죠. 이것을 다시 정상적인 Bob에게 전달합니다. 이렇게 Alice와 Bob를 속이고 그 당사자인척 하는 공격방법이 스푸핑이라고 합니다.

ARP spoofing

 

반대로 Bob이 Alice에게 데이터를 보낼때도 공격자를 거쳐가게 되죠. Bob의 ARP Cache도 오염되어 공격자의 MAC주소로 업데이트되었기 떄문이죠.

공격자는 2계층의 MAC주소를 그 사람인척 위장해야하므로 같은 네트워크에 위치해야합니다. 

대응

- 위에서 본 정적 arp cache를 구성하면 시스템 종료시까지 막을 수 있습니다. 또한 시스템 가동시마다 arp를 정적으로 구성해주어야합니다. 물론 매번 arp를 정적으로 구성하는 것은 까다로울 수 있으니 자동화된 프로그램을 사용하는 것이 좋겠죠.

- arp cache의 변경을 감지하는 프로그램을 사용할 수도 있습니다.

여기까지 ARP에 대한 개념과 설명, 그리고 ARP Cache와 Reply를 통해서 스푸핑할 수 있는 원리에 대해서 알아보았습니다.

 

 

반응형
블로그 이미지

REAKWON

와나진짜

,

 

 

디피-헬만 (Diffie-Hellman) 

디피-헬만(이하 DH)은 대칭키를 키 분배 센터(KDC, Key Distribution Center)없이 대칭키를 생성할 수 있는 암호 프로토콜을 의미합니다. KDC가 뭐냐하면 키를 분배하고 관리해주는 센터를 의미하는데요. 수 많은 호스트가 있는데 암호화 통신을 하려면 그 호스트들의 대칭키를 모두 가지고 있어야하니, KDC에게 키를 얻습니다. KDC는 물론 신뢰성이 큰 센터여야겠지요.  DH는 KDC없이도 대칭키를 서로 합의할 수 있도록 만들어줍니다. 아래의 그 과정을 봅시다.

우선 Alice와 Bob(암호학에서는 Alice와 Bob 이름에 친숙해지셔야합니다.)이 먼저 거쳐야 할 과정이 있습니다.

1) Alice와 Bob은 p와 g를 정하는데요. 매우 큰 300자리가 넘는 매우 큰 소수 p를 정합니다. 그리고 \(1 <= g <= p-1\)인 g를 정합니다. p와 g는 공개되어도 상관없습니다. 누구나 알아도 됩니다.

2) 그 후 Alice는 \(0 <= x < p-1\)인 x를 선택합니다. 이 사이의 수면 아무거나 상관없습니다. 그 후 R1=\(g^{x}\) mod p를 계산합니다. 여기서 mod는 모듈러 연산으로 \(g^{x}\)를 p로 나눈 나머지를 의미합니다. Alice가 정한 x는 Alice 본인만 알아야하는 값입니다. 절대 공개해서는 안되는 값입니다.

3) Bob도 역시 Alice와 마찬가지로 \(0 <= y < p-1\)인 y를 선택합니다. 그 후 R2=\(g^{y}\) mod p를 전달합니다.  y는 Bob만 알고 있어야합니다.

4) 이제 서로 R1과 R2를 당사자들에게 보냅니다. 

5) Alice는 R2\(^{x}\) mod p를 계산합니다. \((g^{y}\) mod p)\(^{x}\) mod p = \((g^{y})^{x}\) mod p = \(g^{yx}\) mod p로 이 값이 결국 대칭키 K가 됩니다.  

6) Bob도 역시 R1\(^{y}\) mod p 를 계산합니다. \((g^{x}\) mod p)\(^{y}\) mod p = \((g^{x})^{y}\) mod p = \(g^{xy}\) mod p로 이 값이 결국 대칭키 K가 됩니다.

결국 둘은 K=(g^{xy}\) mod p 인 대칭키를 갖고 있게 됩니다. 아래의 그림은 그 과정을 나타냅니다.(오타가 있네요. 아래 그림에 Bob이 생성한것은 R1이 아니라 R2입니다.) 

Diffie-Hellman

 

예제

이게껏 설명만 했는데 실제로 계산하는 과정을 보도록 하겠습니다.

Alice와 Bob은 p와 g는 11과 7로 설정했습니다. Alice는 x를 5로 선택하고 Bob은 y를 3으로 선택했다고 치겠습니다. R1=\(g^{5}\) mod p를 보내고, Bob은 R2=\(g^{3}\) mod p으로 하여 서로에게 보냅니다. 이제 Alice는 \((7^{3}\) mod 11)\(^{5}\) mod 11 = \((7^{3})^{5}\) mod 11 = \(7^{15}\) mod 11 의 값 K=10입니다. 이제 Bob도 역시 그렇게 계산을 하는데요. \((7^{5}\) mod 11)\(^{3}\) mod p = \((7^{5})^{3}\) mod 11 = \(7^{15}\) mod 11의 값은 역시 동일한 값으로 10임을 확인할 수 있습니다.

 

 

중간자 공격(Man-In-The-Middle-Attack)

만약 Alice와 Bob 사이에 공격자가 있다면 어떻게 될까요? 어찌 저찌 되어서 Alice가 Bob과 통신해야하는 것을 Eve로 착각했다고 합니다. Bob 역시 Alice와 통신을 해야하는데 Eve가 Alice로 알고 있다고 해봅시다. 그러면 Alice는 R1을 계산해서 Eve에게 주고, Bob도 R2를 계산해서 Eve에게 줍니다. Eve는 자신도 z를 정해서 R3를 양쪽 모두에게 전달해줍니다. 

이렇게 되면 Alice는 R3를 가지고 키 K1을 구할 수 있고 Eve 역시 위의 방법대로 K1을 구할 수 있습니다. Bob도 역시 R3를 가지고 K2를 구할 수 있게 되고 Eve 역시 K2를 구할 수 있습니다. 이제 공격자는 Alice와도 통신할 수 있고 Bob과도 통신할 수 있는 상황이 되었습니다. 공격자가 중간에 위치해서 민감한 정보를 모두 볼 수 있군요. 이런 공격 방식이 중간자 공격이라고 합니다.

man-in-the-middle attack

 

이산대수 공격

만약 Eve가 R1, R2를 가로챌 수 있다고 해봅시다. R1에서 x를 구하고, R2에서 y를 구할 수 있으면 K를 구하는 것을 쉽습니다. 그런 이산대수 공격에 대해서 안전하려면 p를 아주 매우 큰 소수로 정해놓아야합니다. 그래야 x,y를 구하기가 어렵거든요. p-1이 적어도 하나의 큰 소인수(60자리의 10진수 이상)을 가져야합니다. Alice와 Bob은 x,y를 사용 즉시 폐기하는 것이 좋습니다.

 

이상으로 디피-헬만 암호 프로토콜에 대해서 알아보았는데요. 모듈러 연산을 모르신다면 조금 어려울 수가 있는데요. 모듈러 연산에 대해서는 따로 시간을 내어 공부하시는 것이 좋을 듯 합니다. 쉽게 계산하는 규칙들도 존재하거든요. 디피-헬만은 ECC 암호화 알고리즘과 결합하여 사용하기도 합니다. ECDH라고도 하지요. 

DH에서 키를 구하는 문제가 이전 2016년 정보보안 기사 시험에 나온적이 있습니다.

 

 

반응형
블로그 이미지

REAKWON

와나진짜

,