기록공간

4-4장. 막간 : 파일과 디렉터리 본문

OS

4-4장. 막간 : 파일과 디렉터리

입코딩 2020. 7. 23. 22:46
반응형

파일과 디렉터리

저장 장치의 가상화에 대한 두 가지 주요 개념이 개발되었다. 첫 번째는 파일이다. 파일은 단순히 일거나 쓸 수 있는 순차적인 바이트의 배열이다. 각 파일은 저수준의 이름을 갖고 있으며 보통은 숫자로 표현되지만 사용자는 그 이름에 대해서 알지 못한다. 이 저수준의 이름을 아이노드번호(inode number)라고 부른다. 앞으로 이 아이노드번호에 대해서 더 자세히 살펴볼 것이다. 

 

대부분 시스템에서 운영체제는 파일의 구조를 모른다. (예를 들면 어떤 파일이 그림인지, 또는 C 코드인지) 파일 시스템의 역할은 그러한 데이터를 디스크에 안전히 저장하고, 데이터가 요청되면 처음 저장했던 데이터를 돌려주는 것이다. 하지만 이렇게 하는 것은 보기보다 쉽지 않다.

 

두 번째 개념은 디렉터리이다. 파일과 마찬가지로 디렉터리도 저수준의 이름을 갖는다. 하지만 파일과는 다르게 디렉터리의 내용은 구체적으로 정해져 있다. 디렉터리는 <사용자가 읽을 수 있는 이름, 저수준의 이름> 쌍으로 이루어진 목록을 갖고 있다. 저수준 이름 "10"을 갖고 있는 파일이 있는데, 그 파일은 사용자가 알아볼 수 있는 "foo"라는 이름을 갖고 있다고 해 보자. "foo"가 들어있는 디렉터리에는 ("foo", "10")이라는 항목이 있어서 사용자가 읽을 수 있는 이름과 저수준의 이름을 연결하고 있다. 디렉터리의 각 항목은 파일 또는 다른 디렉터리를 가리킨다. 디렉터리 내에 다른 디렉터리를 포함함으로써 사용자는 모든 파일들과 디렉터리들이 저장되어 있는 임의의 디렉터리 트리(directory tree, 또는 디렉터리 계층(directory hierarchy))를 구성할 수 있다.

 

디렉터리 계층은 루트 디렉터리(root directory)부터 시작하며 (UNIX 기반 시스템에서 루트 디렉터리는 '/'으로 표현된다), 원하는 파일이나 디렉터리의 이름을 표현할 때까지 구분자(separator)를 사용하여 하위 디렉터리를 명시할 수 있다. 예를 들어서 사용자가 foo라는 디렉터리를 루트 디렉터리 / 아래에 생성했다고 해 보자. 그리고 foo 디렉터리 안에 bar.txt라는 파일을 생성하였다면, 그 파일들의 절대 경로명(absolute pathname)은 /foo/bar.txt로 표현된다. 

 

위 그림에 나타난 좀 더 복잡한 디렉터리 트리를 살펴보자. 그림에서 유효한 디렉터리들은 /, /foo, /bar, /bar/bar, /bar/foo이고 유효한 파일들은 /foo/bar.txt/bar/foo/bar.txt이다. 디렉터리들과 파일들은 파일 시스템 트리 안에서 서로 다른 위치에 있는 경우, 동일한 이름을 가질 수 있다. (bar.txt는 서로 이름은 같지만 다른 위치에 있기 때문에 존재할 수 있다)

 

이 예제서 파일 이름이 두 부분으로 구성되어 있다는 것을 알 수 있다. bar와 txt가 마침표로 분리되어 있다. 첫 번째 부분은 임의의 이름인 반면에 두 번째 부분은 대체적으로 파일의 종류를 나타내기 위해 사용된다. C코드의 경우 .c로 되어 있으며 이미지의 경우 .jpg, 음악 파일이면 .mp3를 갖고 있다. 하지만 대부분 관용적(convention)일 뿐이다. 파일 이름이 main.c라고 해서 내용이 반드시 C 소스 코드일 필요는 없다. 

 

파일 시스템이 제공하는 훌륭한 기능 하나를 살펴보았다. 파일들을 효율적으로 명명할 수 있는 기능이다. 어떤 자원을 접근하는 가장 첫 단계는 그 대상의 이름을 아는 것이기 때문에 시스템에서 이름짓기 기능은 매우 중요하다. UNIX 시스템상에서 파일 시스템은 디스크, USB, CD-ROM등 다양한 장치에 존재하는 파일들을 통합된 방법으로 접근할 수 있도록 한다. 모든 파일들은 하나의 디렉터리 트리 상에 위치한다.

 

파일 시스템 인터페이스

이번에는 파일 시스템 인터페이스를 좀 더 상세하게 논의해 보자. 파일의 생성과 접근 그리고 삭제 등의 기본부터 시작해 보자.

 

파일의 생성

아주 기본적인 연산인 파일의 생성부터 시작해 보자. open 시스템 콜을 사용하여 파일을 생성할 수 있다. open()을 호출하면서 O_CREATE 플래그를 전달하면 프로그램은 새로운 파일을 만들 수 있다. 다음은 현재의 디렉터리에 "foo"라는 파일을 만드는 코드이다.

int fd = open("foo", O_CREATE | O_WRONLY | O_TRUNC);

open()은 다수의 플래그를 받는다. 이 예제에서 프로그램은 O_CREATE로 파일을 생성하고, 파일이 열렸을 때 쓰기만 가능하도록 O_WRONLY 플래그를 사용하였다. 그리고 O_TRUNC 플래그를 사용하여 파일이 이미 존재할 때는 파일의 크기를 0 byte로 줄여 기존 내용을 모두 삭제한다.

 

open()에 가장 중요한 항목은 리턴 값이다.(파일 디스크립터(file descriptor)) 파일 디스크립터는 프로세스마다 존재하는 정수로서 UNIX 시스템에서 파일을 접근하는 데 사용된다. 물론 해당 파일에 대한 권한을 갖고 있어야 한다. 이러한 측면에서 파일 디스크립터는 특정 동작에 대한 수행 자격을 부여하는 핸들이다. 파일 디스크립터를 파일 객체를 가리키는 포인터로 볼 수도 있다. 그러한 객체를 생성하면, read() 또는 write()와 같은 다른 메소드로 파일에 접근할 수 있다. 

 

파일의 읽기와 쓰기

파일이 있으면, 그 파일들을 읽거나 쓰고 싶을 것이다. 이미 존재하고 있는 파일을 읽는 것부터 시작하자. 커맨드라인을 사용 중이라면 cat라는 프로그램을 사용하여 파일의 내용을 화면에 덤프할 수 있다.

 

이 코드에서는 echo의 출력을 파일 foo로 전송(redirect)하여 그 파일에 "hello"를 저장하도록 하였다. 그런 후에 cat 명령어로 내용을 확인하였다. cat 프로그램은 어떻게 파일 foo에 접근할까?

 

이것을 알아보기 위해서 프로그램이 호출하는 시스템 콜을 추적하는 도구를 사용한다. Linux에는 strace라는 도구가 있다. strace가 하는 일은 프로그램이 실행되는 동안에 호출된 모든 시스템 콜을 추적하고, 그 결과를 화면에 보여준다. 

 

다음의 예를 통해 strace를 사용하여 cat이 어떤 동작을 하는지 알아보자.

 

cat이 가장 먼저 하는 것은 파일을 읽기 위해서 여는 것이다. 몇 가지 짚고 넘어갈 사항이 있다. 파일은 O_RDONLY라는 플래그가 나타내는 것처럼 읽을 수만 있도록 열렸다. 두 번째는 O_LARGEFILE 플래그를 사용하여 64bit 오프셋이 사용되도록 설정하였다. 세 번째는 open()이 성공한 후에 3이라는 값을 파일 디스크립터로 리턴하였다.

 

어째서 첫 번째 open()임에도 불구하고 예상과 달리 0 또는 1이 아닌 3을 리턴하였을까? 그 이유는 이미 프로세스가 세 개의 파일을 열어 놓았기 때문이다. 이미 열려진 세 개의 파일은 표준 입력표준 출력, 그리고 오류를 출력할 수 있는 표준 에러이다. 각각의 파일 디스크립터는 0, 1, 그리고 2로 표현된다. 다른 파일을 처음으로 열게 되면, 거의 확실하게 파일 디스크립터는 3일 것이다.

 

파일 열기가 성공하면 cat는 read() 시스템 콜을 사용하여 파일에서 몇 바이트씩 반복적으로 읽는다. read()의 첫 번째 인자는 파일 디스크립터로서 파일 시스템에 어떤 파일을 읽을 것인지 알려준다. 프로세스는 동시에 여러 파일을 열 수 없기 때문에, 디스크립터는 운영체제가 read 명령이 있어야 할 파일을 알 수 있게 한다. 두 번째 인자는 read() 결과를 저장할 버퍼를 가리킨다. 위의 시스템 콜 추적 예제에서 strace는 읽은 결과인 "hello"를 두 번째 인자 위치에 표시하였다. 세 번째 인자는 버퍼의 크기로서 여기서는 4KB이다. read()가 성공적으로 리턴하면 읽은 바이트 수를 반환한다. (6 반환)

 

이 시점에서 strace의 또 다른 흥미로운 점이 있다. write() 시스템 콜이 결과를 쓰는 대상 파일로 파일 디스크립터 1번을 사용하는 것이다. 앞서 설명했듯이 이 디스크립터는 표준 출력 으로서 "hello"라는 단어를 화면에 나타내기 위해 사용되고, cat이 하기로 되어 있는 작업이다. 그럼 cat 프로그램이 write()를 직접 호출하는 것일까? 그럴지도 모른다. 그렇지 않다면 cat는 라이브러리 루틴인 printf()를 호출했을 것이다. 내부적으로 printf()는 전달받은 문자열에 적절한 포맷을 적용한 후 결과를 표준 출력을 대상으로 write()를 호출하여 화면에 출력한다.

 

출력한 이후 cat 프로그램은 파일의 내용을 더 읽으려고 시도하고, 파일에 남은 바이트가 없기 때문에 read()는 0을 리턴한다. 프로그램은 리턴 값으로 파일을 끝까지 다 읽었음을 알게된다. 그런 후 프로그램은 해당 파일 디스크립터를 인자로 close()를 호출하여 "foo"라는 파일에서 할 일이 끝났음을 표시한다. 이랗ㄱ[ 파일은 닫히고 읽기 작업은 완료된다.

 

파일에 쓰는 것도 비슷한 단계를 거친다. 먼저 파일을 쓰기 위해 열고 write() 시스템을 호출한다. 파일이 큰 경우 write() 시스템 콜을 반복적으로 호출할 수 있다. 그 후에 close()가 호출된다.

 

비 순차적 읽기와 쓰기

지금까지 파일을 읽고 쓰는 과정을 논의하였는데, 모든 접근은 순차적이었다. 즉, 처음부터 파일을 끝까지 읽었고, 쓸 때도 처음부터 끝까지 기록하였다.

 

그렇지만 때로는 파일의 특정 오프셋부터 읽거나 쓰는 것이 유용할 때도 있다. 예를 들면 문서의 인덱스를 만들고 특정 단어를 찾는다고 해 보자. 그러한 경우 문서 내의 임의의 오프셋에서 읽기를 수행해야 할 것이다. 이것을 위해서 lseek()라는 시스템 콜을 사용한다. 여기에 함수의 프로토타입이 있다.

 

첫 번째 인자는 파일 디스크립터이다. 두 번째 인자는 offset으로 파일의 특정 위치를 가리킨다. 세 번째 인자는 역사적인 이유로 whence라고 부르며 탐색 방식을 결정한다. 다음과 같은 키워드로 설정할 수 있다.

 

이 설명에서 알 수 있듯 프로세스가 open()한 각 파일에 대해 운영체제는 "현재" 오프셋을 추적하여 다음 읽기 또는 쓰기 위치를 결정한다. 열린 파일의 개념에는 현재 오프셋이 포함된다. 오프셋은 두 가지 중 하나의 방법으로 갱신된다.  첫 째 N바이트를 읽거나 쓸 때 현재 오프셋에 N이 더해진다. 따라서 각 읽기 또는 쓰기는 암묵적으로 오프셋을 갱신한다. 둘째, 앞서 본 것처럼 lseek로 명시적으로 오프셋을 변경하는 것이다. 

 

lseek()는 디스크 암을 이동시키는 디스크의 탐색(seek) 작업과 아무 관계도 없다는 것이 유의해야 한다. lseek() 호출은 커널 내부에 있는 변수의 값을 변경한다. I/O를 처리할 때 디스크 헤드가 어디에 있는지에 따라서 요청을 처리하기 위해 실제 디스크암을 이동하는 탐색 과정을 수행할 수도 있고 하지 않을 수도 있다.

 

fsync()를 사용한 즉시 기록

write() 호출의 목적은 대부분 해당 데이터를 가까운 미래에 영속 저장 장치에 기록해 달라고 파일 시스템에게 요청하는 것이다. 성능상 이유로 파일 시스템은 쓰기들을 일정 시간(5~30초) 동안 메모리에 모은다.(버퍼링) 일정 간격으로 쓰기 요청이 저장 장치에 전달된다. 응용 프로그램 입장에서는 write() 호출 즉시 쓰기가 완료된 것처럼 보인다. 드물게(쓰기 직전 기계가 크래시 나는 경우) 데이터가 유실되는 경우가 발생한다.

 

어떤 프로그램은 쓰기에 있어서 좀 더 강력한 보장을 필요로 한다. 예를 들어 DBMS(DataBase Management System)의 복원 모듈은 때때로 강제적으로 즉시 디스크에 기록할 수 있는 기능이 필요하다.

 

이러한 류의 응용 프로그램을 지원하기 위해서 대부분의 파일 시스템들은 추가적인 제어 API들을 제공한다. UNIX에서 응용 프로그램에게 제공되는 인터페이스는 fsync(int fd)이다. 프로세스가 특정 파일 디스크립터에 대해서 fsync()를 호출하면 파일 시스템은 지정된 파일의 모든 더티(dirty 즉, 갱신된) 데이터를 디스크로 강제로 내려보낸다. 모든 쓰기들이 처리되면 fsync() 루틴은 리턴한다.

 

fsync()를 사용하는 방법에 관한 간단한 예제(위 그림)를 살펴보자. 코드에서 foo라는 파일을 열어서 데이터를 하나 쓴다. 그리고 나서 fsync()를 호출하여 해당 블럭을 즉시 디스크에 강제적으로 기록한다. fsync()가 리턴하면 응용 프로그램은 데이터가 영속성을 갖게 되었다는 것을 보장받기 때문에, 안전하게 다음으로 진행할 수 있다.

 

물론, 이런 코드가 예상하는 모든 것을 완벽히 보장해 주지는 못한다. 어떤 경우 파일 foo가 존재하는 디렉터리도 fsync() 해줘야 한다. 디렉터리를 함께 fsync()함으로써, 파일 자체와 이 파일이 존재하는 디렉터리 모두 안전하게 디스크에 저장하는 것이 보장된다. 파일이 새로 생성된 경우, 디렉터리를 반드시 fsync() 해줘야 한다. 이러한 문제는 매우 중요하지만 자주 간과되고 있으며 많은 응용 프로그램 수준의 버그를 만들어낸다.

파일 이름 변경

때로는 파일의 이름을 변경하는 것이 매우 유용하다. 명령 행에서 mv 명령으로 파일명을 변경할 수 있다. foo를 bar라는 새로운 이름으로 바꾸는 명령어는 다음과 같다.

 

strace를 사용하면 mv가 rename(char *old, char *new) 라는 두 개의 인자를 갖는 시스템 콜을 호출하는 것을 볼 수 있을 것다. 각 인자는 원래의 파일 이름과 새로운 이름을 나타낸다.

 

rename()은 한 가지 흥미로운 특성을 보장한다. 이 명령어는 (대체적으로) 시스템 크래시에 대해 원자적으로 구현되었다. 이름 변경 중 시스템 크래시가 발생했을 경우, 파일 이름은 원래의 이름이나 새로운 이름, 둘 중 하나를 갖게 되며 그 사이의 중간 상태는 발생하지 않는다.

 

파일 정보 추출

파일 시스템은 각 파일에 대한 정보를 보관한다. 파일에 대한 정보를 메타데이터(metadata)라고 부른다. 어떤 파일의 메타데이터를 보려면 stat() 이나 fstat() 시스템 콜을 사용한다. 이 호출들은 파일에 대한 경로명을 입력으로 받는다. stat의 구조는 다음과 같다.

 

각 파일에 관한 많은 정보가 있다는 것을 알 수 있다. 파일의 크기(바이트 단위), 저수준 이름(아이노드 번호), 소유권, 파일이 접근되고 변경된 시간, 그 외에도 많은 정보가 있다. 정보를 확인하기 위해 stat을 사용한다.

 

일반적으로 파일 시스템은 아이노드에 이 정보를 보관한다. (아이노드 : 정보를 저장하는 디스크 자료구조)

 

파일 삭제

파일 삭제는 어떻게 할까? UNIX에서는 rm이라는 프로그램을 실행하기만 하면 된다. 그러면 rm은 어떤 시스템 콜을 사용하여 파일을 삭제할까?

 

strace를 통해서 알아보도록 하자. 여기서 이번에는 "foo"파일을 삭제해보자.

 

관련 없는 내용을 제거하니, 명칭으로는 무엇을 하는지 알 수 없는 unlink()라는 시스템 콜이 남았다. unlink()는 지워야 하는 파일 이름을 인자로 받은 후에 성공하면 0을 리턴한다. 하지만 왜 시스템 콜의 이름이 "remove" 또는 "delete"가 아니라 unlink일까? 답을 이해하기 위해서는 파일뿐만 아니라 디렉터리에 대해서도 이해를 해야 한다.

 

디렉터리 생성

디렉터리 관련 시스템 콜들은 디렉터리를 생성하고, 읽고, 삭제한다. 단, 디렉터리에는 절대로 직접 쓸 수 없다. 디렉터리는 파일 시스템의 메타데이터로 분류되며, 항상 간접적으로만 변경된다. 예를 들어 파일이나 디렉터리 또는 다른 종류의 객체들을 생성함으로써 디렉터리를 변경할 수 있다. 파일 시스템은 이런 식으로 디렉터리의 내용이 항상 예상과 일치하도록 보장한다.

 

디렉터리 생성을 위한 시스템 콜로 mkdir()이 있다. 그러면 mkdir 프로그램이 실행될 때 무슨 일이 벌어지는 살펴보자. foo라는 디렉터리를 생성해보자.

prompt > strace mkdir foo
...
mkdir("foo", 0777) = 0
...
prompt >

처음 디렉터리가 생성되면 빈 상태이지만, 사실 아주 기본적인 내용이 들어 있기는 하다. 빈 디렉터리에는 실제로 두 개의 항목이 존재한다. 하나의 항목은 디렉터리 자신을 나타내기 위한 것이고, 다른 항목은 자신의 부모 디렉터리를 가리키기 위한 것이다. (전자는 "."(dot), 후자는 ".."(dot-dot)이라고 한다) 이를 확인하기 위해서는 ls 명령에 -a 플래그를 전달하면 된다.

 

디렉터리 읽기

이제 디렉터리를 생성했으니 읽어보도록 하자. 사실 ls 프로그램이 하는 일이 바로 그것이다. ls와 유사한 도구를 직접 만들어 어떻게 동작하는지 알아보자.

 

디렉터리의 open은 파일을 open하는 것과는 다른 새로운 시스템 콜을 사용한다. 아래 예제 프로그램은 디렉터리의 내용을 출력한다. 

 

이 프로그램은 opendir(), readdir(), 및 closedir()를 사용한다. 간단한 반복문을 사용하여 디렉터리 항목을 하나씩 읽은 후에 디렉터리의 각 파일의 이름과 아이노드 번호를 출력한다. dirent 구조체는 자료 구조 형태의 각 디렉터리 항목에 저장된 정보를 보여준다.

 

디렉터리에는 많은 정보가 있지 않기 때문에(이름과 아이노드 번호 매핑 외 몇가지만 제공) 프로그램은 각 파일에 stat()를 호출하여 파일 크기와 같은 구체적인 정보를 얻는다. 

 

디렉터리 삭제

마지막으로 rmdir() 시스템 콜을 사용하여 디렉터리를 삭제할 수 있다. 파일 삭제와 다른 점음 디렉터리 삭제는 하나의 명령으로 아주 많은 양의 데이터를 지울 수 있기 때문에 좀 더 위험하다는 것이다. 때문에 rmdir()은 디렉터리를 지우기 전에 디렉터리가 비어 있어야 한다는 조건이 붙는다. 비어있지 않은 디렉터리에 대해 rmdir()을 호출하면 실패한다.

 

하드링크

파일 삭제 시 왜 unlink()를 사용하는지를 이해하기 위해 이제 파일 시스템 트리에 항목을 추가하는 새로운 시스템 콜 link()를 알아보자. link() 시스템 콜은 두 개의 인자를 받는데, 하나는 원래의 경로명이고, 다른 하나는 새로운 경로명이다. 원래 파일 이름에 새로운 이름을 link(연결) 하면 동일한 파일을 접근할 수 있는 새로운 방법을 만들게 된다.(마치 C언어에 포인터와 비슷하다) 명령행 프로그램 ln이 그 일을 하며 아래 예제와 같다.

 

"hello"라는 파일을 생성하고 이름을 file이라고 지었다. ln 프로그램을 사용하여 이 파일의 하드 링크를 생성한다. 이후부터는 이 파일을 보려면 file 또는 file2를 열면 된다.

 

link는 새로 링크하려는 이름 항목을 디렉터리에 생성하고, 원래 파일과 같은 아이노드 번호를 가리키도록 한다. 파일은 복사되지 않는다. 대신 같은 파일을 가리키는 두 개의 이름(file, file2)이 생성된다. 각 파일의 아이노드 번호를 출력하여 직접 확인해보면 다음과 같다.

 

ls에 -i 플래그를 사용하면 각 파일의 아이노드 번호를 파일 이름과 함께 출력한다. 이로써 link가 정확히 어떤것인지 알 수 있다. 동일한 아이노드 번호에 대한 새로운 링크를 생성했다.

 

unlink()가 왜 unlink()가 되었는지 이제 이해되기 시작했을 것이다. 파일을 생성할 때 사실은 두 가지 작업을 하게 된다. 첫 번째는 파일 관련 모든 정보를 관리하는 자료 구조를 만드는 것이다. 파일 크기와 디스크 블럭의 위치 등이 포함된다. 두 번째는 해당 파일에 사람이 읽을 수 있는 이름을 연결하고 그 연결 정보를 디렉터리에 생성하는 것이다.

 

파일 시스템에 파일의 하드 링크를 생성한 후에는 원래의 파일 이름(file)과 새로 생성된 파일 이름(file2) 간에는 차이가 없다. 사실 그 두 개의 파일 이름은 아이노드 번호에서 찾을 수 있는 파일의 메타데이터에 대한 연결일 뿐이다.

 

파일을 unlink하면 아이노드 번호의 참조 횟수(reference counter)를 검사한다. 이 참조 횟수가 특정 아이노드에 대해 다른 이름이 몇 개나 연결되어 있는지 관리한다. unlink가 호출되면 이름과 해당 아이노드 반호 간의 연결을 끊고 참조 횟수를 하나 줄인다. 참조 횟수가 0에 도달하면 파일 시스템은 비로소 아이노드와 관련된 데이터 블럭을 해제하여 파일을 진정으로 삭제한다.

 

심볼릭 링크

또 다른 유용한 링크가 있는데, 심볼릭 링크(symbolic link)가 바로 그것이다. 하드 링크는 제한이 많은 편이다. 디렉터리에 대해서는 하드 링크를 만들 수 없으며, 다른 디스크 파티션에 있는 파일에 대해서도 하드 링크를 걸 수 없는 등의 제한이 있다. 그렇게 하여 새로운 종류의 심볼릭 링크가 만들어지게 되었다.

 

심볼릭 링크를 만들기 위해 동일한 ln 프로그램을 사용할 수 있다. 대신 -s 플래그를 전달해야 한다.

 

만드는 방법은 하드링크와 비슷해 보인다. 원래의 파일은 기본의 이름인 file 뿐만 아니라 이제 file2라는 심볼릭 링크 이름으로도 접근할 수 있다.

 

표면적으로는 하드링크와 비슷해 보이지만 사실 매우 다르다. 첫 번째 차이는 심볼릭 링크는 다른 형식의 독립된 파일이라는 것이다. 심볼릭 링크는 파일 시스템에 존재하는 세 번째 종류의 유형이다. 

 

ls를 실행시켜도 그 사실을 알 수 있다. ls의 긴 형식의 출력의 첫 글자를 자세히 살펴보면 가장 왼쪽 열의 글자가 일반 파일에 대해서는 -로 표기되고 디렉터리는 d로 그리고 심볼릭 링크에 대해서는 l로 표시되고 있는 것을 알 수 있다. 심지어 심볼릭 링크는 그 크기와 연결 대상도 보여주고 있다.

반응형
Comments