웹 서버는 서로 다른 수천 개의 클라이언트들과 동시에 통신한다. 이 서버들은 익명의 클라이언트로부터 받는 모든 요청을 처리하는 것뿐만 아니라 서버와 통신하고 있는 클라이언트를 추적해야 할 수도 있다.
HTTP는 익명으로 사용하며 상태가 없고 요청과 응답으로 통신하는 프로토콜이다. 웹 서버는 요청을 보낸 사용자를 식별하거나 방문자가 보낸 연속적인 요청을 추적하기 위해 약간의 정보를 이용할 수 있다.
개별 인사
온라인 쇼핑이 개인에게 맞춰져 있는 것처럼 느끼게 하려고 사용자에게 특화된 환영 메시지나 페이지 내용을 만든다.
사용자 맞춤 추천
온라인 상점은 고객의 흥미가 무엇인지 학습해서 고객이 좋아할 것이라고 예상되는 제품들을 추천할 수 있다. 고객의 생일이나 다른 중요한 날이 다가오면 특별한 제품을 제시하기도 한다.
저장된 사용자 정보
온라인 쇼핑 고객은 복잡한 주소와 신용카드 정보를 매번 입력한느 것을 싫어한다. 이런 정보를 데이터베이스에 저장하는 온라인 상점도 있다. 온라인 쇼핑이 당신을 한번 식별하고 나면, 쇼핑을 더 편하게 할 수 있게 저장된 사용자 정보를 사용할 수 있다.
세션 추적
HTTP 트랜잭션은 상태가 없다. 각 요청 및 응답은 독립적으로 일어난다. 많은 웹사이트에서 사용자가 사이트와 상호작용할 수 있게 사용자의 상태를 남긴다. 이렇게 상태를 유지하려면, 웹 사이트는 사용자에게서 오는 HTTP 트랜잭션을 식별할 방법이 필요하다.
사용자 식별 기술에는 다음과 같은 것이 있다.
사용자 식별 관련 정보를 전달하는 HTTP 헤더들
클라이언트IP 주소 추적으로 알아낸 IP 주소로 사용자를 식별
사용자 로그인 인증을 통한 사용자 식별
URL에 식별자를 포함하는 기술인 뚱뚱한(fat) URL
식별 정보를 지속해서 유지하는 강력하면서도 효율적인 기술인 쿠키
HTTP 헤더
사용자에 대한 정보를 전달하는 HTTP 헤더
위 표는 사용자에 대한 정보를 저장하는 가장 일반적인 일곱 가지 HTTP 요청 헤더가 기술되어 있다.
클라이언트 IP 주소
웹 서버는 HTTP 요청을 보내는 반대쪽 TCP 커넥션의 IP 주소를 알아낼 수 있다. 하지만 클라이언트 IP 주소로 사용자를 식별하는 방식은 다음과 같은 약점을 갖는다.
클라이언트 IP 주소는 사용자가 아닌, 사용하는 컴퓨터를 가리킨다. 만약 여러 사용자가 같은 컴퓨터를 사용한다면 그들을 식별할 수 없다.
많은 인터넷 서비스 제공자(ISP)는 사용자가 로그인하면 동적으로 IP 주소를 할당한다. 로그인한 시단에 따라, 사용자는 매번 다른 주소를 받으므로, 웹 서버는 사용자를 IP 주소로 식별할 수 없다.
보안을 강화하고 부족한 주소들을 관리하려고 많은 사용자가 네트워크 주소 변환(Network Address Translation, NAT) 방화벽을 통해 인터넷을 사용한다. 이 NAT 장비들은 클라이언트의 실제 IP 주소를 방화벽 뒤로 숨기고, 클라이언트의 실제 IP 주소를 내부에서 사용하는 하나의 방화벽 IP 주소로 변환한다.
HTTP 프락시와 게이트웨이는 원 서버에 새로운 TCP 연결을 한다. 웹 서버는 클라이언트의 IP 주소 대신 프락시 서버의 IP 주소를 본다.
사용자 로그인
IP 주소로 사용자를 식별하려는 수동적인 방식보다, 웹 서버는 사용자 이름과 비밀번호로 인증(로그인)할 것을 요구해서 사용자에게 명시적으로 식별 요청을 할 수 있다.
웹 사이트 로그인이 더 쉽도록 HTTP는 WWW-Autenticate 와 Authorication 헤더를 사용해 웹 사이트에 사용자 이름을 전달하는 자체적인 체계를 가지고 있다. 한번 로그인하면, 브라우저는 사이트로 보내는 모든 요청에 이 로그인 정보를 함께 보내므로 웹 서버는 그 로그인 정보는 항상 확인할 수 있다.
HTTP 인증 헤더를 이용한 사용자 등록
뚱뚱한 URL
어떤 웹 사이트는 사용자의 URL 마다 버전을 기술하여 사용자를 식별하고 추적하였다. 보통, URL은 URL 경로의 처음이나 끝에 어떤 상태 정보를 추가해 확장한다. 사용자가 그 사이트를 돌아다니면, 웹 서버는 URL에 있는 상태 정보를 유지하는 하이퍼링크를 동적으로 생성한다.
사용자의 상태 정보를 포함하고 있는 URL을 뚱뚱한 URL이라고 한다.
뚱뚱한 URL 예시
하지만 이 기술에는 여러 문제가 있다.
못생긴 URL
브라우저에 보이는 뚱뚱한 URL은 새로운 사용자들에게 혼란을 준다.
공유하지 못하는 URL
뚱뚱한 URL은 특정 사용자와 세션에 대한 상태 정보를 포함한다. 만약 그 주소를 누군가에게 메일로 보내면, 당신의 누적된 개인 정보를 본의 아니게 공유하게 된다.
캐시를 사용할 수 없음
URL로 만드는 것은 URL이 달라지기 때문에 기존 캐시에 접근할 수 없다는 것을 의미한다.
서버 부하 가중
서버는 뚱뚱한 URL에 해당하는 HTML 페이지를 다시 그려야 한다.
이탈
사용자가 링크를 타고 다른 사이트로 이동하거나 특정 URL을 요청해서 의도치 않게 뚱뚱한 URL 세션에서 '이탈'하기 쉽다. 사용자는 서비스를 사용하는 동안, 사전에 세션 정보가 추가된 링크만을 사용해야 뚱뚱한 URL이 문제없이 동작할 수 있다.
세션 간 지속성의 부재
사용자가 특정 뚱뚱한 URL을 북마킹하지 않는 이상, 로그아웃하면 모든 정보를 잃는다.
쿠키
쿠기는 사용자를 식별하고 세션을 유지하는 방식 중에서 현재까지 가장 널리 사용하는 방식이다.
쿠키의 타입
쿠키는 크게 세션 쿠키(session cookie)와 지속 쿠키(persistent cookie) 두 가지 타입으로 나눌 수 있다. 세션 쿠키는 사용자가 사이트를 탐색할 때, 관련한 설정과 선호 사항들을 저장하는 임시 쿠키다. 세션 쿠키는 사용자가 브라우저를 닫으면 삭제된다. 지속 쿠키는 삭제되지 않고 더 길게 유지될 수 있다. 지속 쿠키는 디스크에 저장되어, 브라우저를 닫거나 컴퓨터를 재시작하더라도 남아있다. 지속 쿠키는 사용자가 주기적으로 방문하는 사이트에 대한 설정 정보나 로그인 이름을 유지하려고 사용한다.
세션 쿠키와 지속 쿠키의 다른 점은 파기되는 시점뿐이다.
쿠키는 어떻게 동작하는가
쿠키는 서버가 사용자에게 "안녕, 내 이름은.."라고 적어서 붙이는 스티커와 같다. 사용자가 웹 사이트에 방문하면, 웹 사이트는 서버가 사용자에게 붙인 모든 스티커를 읽을 수 있다.
웹 서버는 사용자가 다시 돌아왔을 때, 해당 사용자를 식별하기 위한 유일한 값을 쿠키에 할당한다. 쿠키는 임의의 이름=값 형태의 리스트를 가지고, 그 리스트는 Set-Cookie 혹은 Set-Cookie2(확장 헤더) 같은 HTTP 응답 헤더에 기술되어 사용자에게 전달한다.
쿠키는 어떤 정보든 포함할 수 있지만, 서버가 사용자 추적 용도로 생성한 유일한 단순 식별 번호만 포함하기도 한다.
쿠키 상자 : 클라이언트 측 상태
쿠키의 기본적인 발상은 브라우저가 서버 관련 정보를 저장하고, 사용자가 해당 서버에 접근할 때마다 그 정보를 함께 전송하게 하는 것이다. 브라우저는 쿠키 정보를 저장할 책임이 있는데, 이 시스템을 '클라이언트 측 상태'라고 한다.
사용자에게 쿠키 할당
구글 크롬 쿠키
구글 크롬은 Cookies 라는 SQLite 파일에 쿠키를 저장한다.
이 SQLite 파일에 있는 각 행이 쿠키 한 개에 해당한다.
creation_utc
쿠키가 생성된 시점을 알려주는데, 그 값은 Jan 1, 1970 00:00:00 GMT로부터 생성된 시간을 초 단위로 기술한다.
host_key
쿠키의 도메인이다.
name
쿠키의 이름이다.
value
쿠키의 값이다.
path
쿠키와 관련된 도메인에 있는 경로다.
expire_utc
쿠키의 파기 시점을 알려주는데, 그 값은 Jan 1, 1970 00:00:00 GMT로부터 파기될 시간을 초 단위로 기술한다.
secure
이 쿠키를 SSL 커넥션일 경우에만 보낼지를 가리킨다.
쿠키와 캐싱
쿠키 트랜잭션과 관련된 문서를 캐싱하는 것은 주의해야 한다. 이전 사용자의 쿠키가 다른 사용자에게 할당돼버리거나, 누군가의 개인 정보가 다른 이에게 노출되는 최악의 상황이 일어날 수도 있다.
캐시되지 말아야 할 문서가 있다면 표시하라
Set-Cookie 헤더를 캐시 하는 것에 유의하라 (같은 Set-Cookie 헤더를 여러 사용자에게 보내면, 사용자 추적에 실패한다.)
Cookie 헤더를 가지고 있는 요청을 주의하라
쿠키, 보안 그리고 개인정보
쿠키를 사용하지 않도록 비활성화시킬 수 있고, 로그 분석 같은 다른 방법으로 대체하는 것도 가능하므로, 그 자체가 보안상으로 엄청나게 위험한 것은 아니다. 사실, 원격 데이터베이스에 개인 정보를 저장하고 해당 데이터의 키 값을 쿠키에 저장하는 방식을 표준으로 사용하면, 클라이언트와 서버 사이에 예민한 데이터가 오가는 것을 줄일 수 있다.
웹 캐시는 자주 쓰이는 문서의 사본을 자동으로 보관하는 HTTP 장치다. 웹 요청이 캐시에 도착했을 때, 캐시된 로컬 사본이 존재한다면, 그 문서는 원 서버가 아니라 그 캐시로부터 제공된다.
캐시는 불필요한 데이터 전송을 줄여서, 네트워크 요금으로 인한 비용을 줄여준다.
캐시는 네트워크 병목을 줄여준다. 대역폭을 늘리지 않고도 페이지를 빨리 불러 올 수 있게 된다.
캐시는 원 서버에 대한 요청을 줄여준다. 서버는 부하를 줄일 수 있으며 더 빨리 응답할 수 있게 된다.
페이지를 먼 곳에서 불러올수록 시간이 많이 걸리는데, 캐시는 거리로 인한 지연을 줄여준다.
대역폭 병목
캐시는 또한 네트워크 병목을 줄여준다. 많은 네트워크가 원격 서버보다 로컬 네트워크 클라이언트에 더 넓은 대역폭을 제공한다. 클라이언트들이 서버에 접근할 때의 속도는, 그 경로에 있는 가장 느린 네트워크의 속도와 같다. 만약 클라이언트가 빠른 LAN에 있는 캐시로부터 사본을 가져온다면, 캐싱은 성능을 대폭 개선할 수 있을 것이다(특히 큰 문서들에 대해).
캐시는 대역폭으로 인한 병목을 개선할 수 있다.
위 그림처럼 애틀란타 본사로부터 5MB 크기의 물품 목록 파일을 받는데 30초가 걸릴 수 있다. 만약 문서가 샌프란시스코의 사무실에 캐시되어 있다면, 로컬 사용자는 같은 문서를 이더넷 접속을 통해 1초 미만의 시간에 가져올 수 있다. 대역폭은 큰 문서에 대해 현저한 지연을 일으키며, 속도는 네트워크 종류의 차이에 따라 극적으로 달라진다.
갑작스런 요청 쇄도(Flash Crowds)
캐싱은 갑작스런 요청 쇄도에 대처하기 위해 특히 중요하다. 갑작스런 사건으로 인해 많은 사람이 거의 동시에 웹 문서에 접근할 때 이런 일이 발생한다. 이 결과로 초래된 불필요한 트래픽 급증은 네트워크와 웹 서버의 심각한 장애를 일으킬 수 있다.
웹 서버는 매일 수십억 개의 웹페이지를 쏟아 낸다. 웹서버는 당신에게 날씨를 알려주고, 온라인 쇼핑 카트에 물건을 싣고, 오랫동안 만나지 못했던 고등학교 친구를 찾을 수 있게 해준다. 웹 서버는 월드 와이드 웹의 일꾼이다.
웹 서버가 하는 일
커넥션을 맺는다 -- 클라이언트의 접속을 받아들이거나, 원치 않는 클라이언트라면 닫는다.
요청을 받는다 -- HTTP 요청 메시지를 네트워크로부터 읽어 들인다.
요청을 처리한다 -- 요청 메시지를 해석하고 행동을 취한다.
리소스에 접근한다 -- 메시지에서 지정한 리소스에 접근한다.
응답을 만든다 -- 올바른 헤더를 포함한 HTTP 응답 메시지를 생성한다.
응답을 보낸다 -- 응답을 클라이언트에게 돌려준다.
트랜잭션을 로그로 남긴다 -- 로그파일에 트랜잭션 완료에 대한 기록을 남긴다.
기본 웹 서버 요청의 단계
단계 1 : 클라이언트 커넥션 수락
클라이언트가 이미 서버에 대해 열려있는 지속적 커넥션을 갖고 있다면, 클라이언트는 요청을 보내기 위해 그 커넥션을 사용할 수 있다. 그렇디 않다면, 클라이언트는 서버에 대한 새 커넥션을 열 필요가 있다.
클라이언트가 웹 서버에 TCP 커넥션을 요청하면, 웹 서버는 그 커넥션을 맺고 TCP 커넥션에서 IP 주소를 추출하여 커넥션 맞은편에 어떤 클라이언트가 있는지 확인한다.
웹 서버는 어떤 커넥션이든 마음대로 거절하거나 즉시 닫을 수 있다.
단계 2 : 요청 메시지 수신
커넥션에 데이터가 도착하면, 웹 서버는 네트워크 커넥션에서 그 데이터를 읽어 들이고 파싱하여 요청 메시지를 구성한다.
요청 메시지를 파싱할 때, 웹 서버는 다음과 같은 일을 한다.
요청줄을 파싱하여 요청 메서드, 지정된 리소스의 식별자(URI), 버전 번호를 찾는다.
메시지 헤더들을 읽는다. 각 메시지 헤더는 CRLF로 끝난다.
헤더의 끝을 의미하는 CRLF로 끝나는 빈 줄을 찾아낸다.
요청 본문이 있다면, 읽어 들인다.
요청 메시지를 파싱할 때, 웹 서버는 입력 데이터를 네트워크로부터 불규칙적으로 받는다.
단계 3 : 요청 처리
웹 서버가 요청을 받으면, 서버는 요청으로부터 메서드, 리소스, 헤더, 본문을 얻어내어 처리한다.
단계 4 : 리소스의 매핑과 접근
웹 서버는 리소스 서버다. HTML 페이지나 JPEG 이미지 같은 미리 만들어진 콘텐츠를 제공하며, 마찬가지로 서버 위에서 동작하는 리소스 생성 애플리케이션을 통해 만들어진 동적 콘텐츠도 제공한다.
웹 서버가 클라이언트에 콘텐츠를 전달하려면, 그전에 요청 메시지의 URI에 대응하는 알맞은 콘텐츠나 콘텐츠 생성기를 웹 서버에서 찾아서 그 콘텐츠의 원천을 식별해야 한다.
요청 URI를 local 웹 서버 리소스에 매핑
단계 5 : 응답 만들기
한번 서버가 리소스를 식별하면, 서버는 요청 메서드로 서술되는 동작을 수행한 뒤 응답 메시지를 반환한다. 응답 메시지는 응답 상태 코드, 응답 헤더, 그리고 응답 본문을 포함한다. 응답 메시지는 주로 다음을 포함한다.
응답 본문의 MIME 타임을 서술하는 Content-Type 헤더
응답 본문의 길이를 서술하는 Content-Length 헤더
실제 응답 본문의 내용
MIME 타입 목록 파일
단계 6 : 응답 보내기
서버는 여러 클라이언트에 대한 많은 커넥션을 가질 수 있다. 그들 중 일부는 아무것도 안하고 있는 상태고, 일부는 서버로 데이터를 보내고 있으며, 또 다른 일부는 클라이언트로 돌려줄 응답 데이터를 실어 나르고 있을 것이다. 서버는 커넥션 상태를 추적해야 하며 지속적인 커넥션은 특별히 주의해서 다룰 필요가 있다. 비지속적인 커넥션이라면, 서버는 모든 메시지를 전송했을 때 자신쪽의 커넥션을 닫을 것이다
단계 7 : 로깅
트랜잭션이 완료되었을 때 웹 서버는 트랜잭션이 어떻게 수행되었는지에 대한 로그를 로그파일에 기록한다. 대부분의 웹 서버는 로깅에 대한 여러 가지 설정 양식을 제공한다.
어떤 초등학교를 방문해 '홍길동' 학생을 찾는 방법은 두 가지다. 첫째는, 1학년 1반부터 6학년 맨 마지막 반까지 모든 교실을 돌며 홍길동 학생을 찾는 것이다. 둘째는, 교무실에서 학생 명부를 조회해 홍길동 학생이 있는 교실만 찾아가는 것이다. 둘 중 어느 쪽이 빠를까? 홍길동 학생이 많다면 전자가 빠르고, 몇 안되면 후자가 빠르다.
데이터베이스 테이블에서 데이터를 찾는 방법도 아래 두 가지다. 수십 년에 걸쳐 DBMS가 발전해 왔는데도 이 두 방법에서 크게 벗어나지 못하고 있다.
테이블 전체를 스캔한다.
인덱스를 이용한다.
인덱스 튜닝의 두 가지 핵심요소
인덱스는 큰 테이블에서 소량 데이터를 검색할 때 사용한다. 온라인 트랜잭션 처리 시스템에서는 소량 데이터를 주로 검색하므로 인덱스 튜닝이 무엇보다 중요하다.
세부적인 인덱스 튜닝 방법으로 여러 가지가 있지만, 핵심요소는 크게 두 가지로 나뉜다. 첫번째는 인덱스 스캔 과정에서 발생하는 비효율을 줄이는 것이다. 즉 '인덱스 스캔 효율화 튜닝'이다.
예를 들어, 학생명부에서 키가 170cm ~ 173cm인 홍길동 학생을 찾는 경우로 예를 들어보자. 학생명부를 이름과 키순으로 정렬해 두었다면, 소량만 스캔하면 된다.
이름
키
학년-반-번호
강수지
171
4학년 3반 37번
김철수
180
3학년 2반 13번
...
이영희
172
6학년 4반 19번
...
홍길동
168
2학년 6반 24번
홍길동
170
5학년 1반 16번
홍길동
173
1학년 5반 15번
....
반면, 학생명부를 시력과 이름순으로 정렬해 두었다면, 똑같이 두 명을 찾는데도 많은 양을 스캔해야 한다.
시력
이름
학년-반-번호
168
홍길동
....
170
홍길동
171
강수지
172
이영희
173
홍길동
...
180
김철수
인덱스 튜닝의 두 번째 핵심요소는 테이블 액세스 횟수를 줄이는 것이다. 인덱스 스캔 후 테이블 레코드를 액세스할 때 랜덤 I/O 방식을 사용하므로 이를 '랜덤 액세스 최소화 튜닝'이라고 한다.
인덱스 스캔 효율화 튜닝과 랜덤 액세스 최소화 튜닝 둘 다 중요하지만, 더 중요한 하나를 고른다면 랜덤 액세스 최소화 튜닝이다. 성능에 미치는 영향이 크기 때문이다. SQL 튜닝은 랜덤 I/O와의 전쟁이다.
인덱스 구조
인덱스는 대용량 테이블에서 필요한 데이터만 빠르게 효율적으로 액세스하기 위해 사용하는 오브젝트다. 모든 책 뒤쪽에 있는 색인과 같은 역할을 한다. 데이터베스에서 인덱스 없이 데이터를 검색하려면, 테이블을 처음부터 끝까지 모두 읽어야 한다. 반면, 인덱스를 이용하면 일부만 읽고 멈출 수 있다. 즉, 범위 스캔(Range Scan)이 가능하다. 범위 스캔이 가능한 이유는 인덱스가 정렬돼 있기 때문이다.
DBMS는 일반적으로 B*Tree 인덱스를 사용한다. 나무(Tree)를 거꾸로 뒤집은 모양이여서 뿌리(Root)가 위쪽에 있고, 가지(Branch)를 거쳐 맨 아래에 잎사귀(Leaf)가 있다.
인덱스 구조
루트와 브랜치 블록에 있는 각 레코드는 하위 블록에 대한 주소값을 갖는다. 키값은 하위 블록에 저장된 키값의 범위를 나타낸다.
ROWID = 데이터 블록 주소 + 로우 번호
데이터 블록 주소 = 데이터 파일 번호 + 블록 번호
블록 번호 : 데이터파일 내에서 부여한 상대적 순번
로우 번호 : 블록 내 순번
인덱스 탐색 과정은 수직적 탐색과 수평적 탐색으로 나눌 수 있다.
수직적 탐색 : 인덱스 스캔 시작지점을 찾는 과정
수평적 탐색 : 데이터를 찾는 과정
인덱스 수직적 탐색
정렬된 인덱스 레코드 중 조건을 만족하는 첫 번째 레코드를 찾는 과정이다. 즉, 인덱스 스캔 시작지점을 찾는 과정이다.
인덱스 수직적 탐색은 루트(Root) 블록에서부터 시작한다. 루트를 포함해 브랜치(Branch) 블록에 저장된 각 인덱스 레코드는 하위 블록에 대한 주소값을 갖는다. 루트에서 시작해 리프(Leaf) 블록까지 수직적 탐색이 가능한 이유다.
수직적 탐색 과정에 찾고자 하는 값보다 크거나 같은 값을 만나면, 바로 직전 레코드가 가리키는 하위 블록으로 이동한다.
수직적 탐색은 '조건을 만족하는 레코드'를 찾는 과정이 아니라 '조건을 만족하는 첫 번째 레코드'를 찾는 과정임을 반드시 기억해야 한다.
인덱스 수평적 탐색
수직적 탐색을 통해 스캔 시작점을 찾았으면, 찾고자 하는 데이터가 더 안 나타날 때까지 인덱스 리프 블록을 수평적으로 스캔한다. 인덱스에서 본격적으로 데이터를 찾는 과정이다.
인덱스 리프 블록끼리는 서로 앞뒤 블록에 대한 주소값을 갖는다. 즉, 양방향 연결 리스트(double linked list) 구조다. 좌에서 우로, 또는 우에서 좌로 수평적 탐색이 가능한 이유다.
인덱스를 수평적으로 탐색하는 이유는 첫째, 조건절을 만족하는 데이터를 모두 찾기 위해서고 둘째, ROWID를 얻기 위해서다.
예를 들어, 버퍼캐시에서 20번 블록을 찾고자 하다고 가정해보자. 블록 번호를 5로 나누면 나머지가 0이다. 이 블록이 캐싱돼 있다면 버퍼 헤더가 첫 번째 해시 체인에 연결돼 있을 것이다. 이 블록이 캐싱돼 있다면 버퍼 헤더가 첫 번째 해시 체인에 연결돼 있을 것이므로 찾을 때 항상 첫 번째 해시 체인만 탐색하면 된다.
버퍼캐시에서 블록을 찾을 때 이처럼 해시 알고리즘으로 버퍼 헤더를 찾고, 거기서 얻은 포인터(Pointer)로 버퍼 블록을 액세스하는 방식을 사용한다.
같은 입력 값은 항상 동일한 해시 체인(=버킷)에 연결됨
다른 입력 값이 동일한 해시 체인에 연결될 수 있음
해시 체인 내에서는 정렬이 보장되지 않음
버퍼캐시는 SGA 구성요소이므로 버퍼캐시에 캐싱된 버퍼블록은 모두 공유자원이다. 공유자원은 말 그대로 모두에게 권한이 있기 때문에 누구나 접근할 수 있다.
두 개 이상의 프로세스가 동시에 접근하려고 할 때는 문제가 발생한다. 블록 정합성에 문제가 생길 수 있기 때문이다. 따라서 내부에서는 한 프로세스씩 순차적으로 접근하도록 구현해야 하며, 이를 위해 직렬화(serialization) 메커니즘이 필요하다.
'I/O = 잠(SLEEP)'이라고 생각하면 쉽다. OS 또는 I/O 서브시스템이 I/O를 처리하는 동안 프로세스는 잠을 자기 때문이다. 프로세스가 일하지 않고 잠을 자는 이유는 여러가지가 있지만, I/O가 가장 대표적이고 절대 비중을 차지한다.
프로세스(Process)는 '실행 중인 프로그램'이며, 생명주기를 갖는다. 즉, 생성(new) 이후 종료(terminated) 전까지 준비(ready)와 실행(running)과 대기(waiting) 상태를 반복한다. 실행 중인 프로세스는 interrupt에 의해 수시로 실행 준비 상태(Runnable Queue)로 전환했다가 다시 실행 상태로 전환한다. 여러 프로세스가 하나의 CPU를 공유할 수 있지만, 특정 순간에는 하나의 프로세스만 CPU를 사용할 수 있기 때문에 이런 메커니즘이 필요하다.
프로세스 생명주기
interrupt 없이 열심히 일하던 프로세스도 디스크에서 데이터를 읽어야 할 땐 CPU를 OS에 반환하고 잠시 수면(waiting) 상태에서 I/O가 완료되기를 기다린다. 정해진 OS 함수를 호출(I/O Call)하고 CPU를 반환한 채 알람을 설정하고 대기 큐(Wait Queue)에서 잠을 자는 것이다. 열심히 일해야 할 프로세스가 한가하게 잠을 자고 있으니 I/O가 많으면 성능이 느릴 수 밖에 없다.
데이터베이스 저장 구조
데이터를 저장하려면 먼저 테이블스페이스를 생성해야 한다. 테이블스페이스는 세그먼트를 담는 콘테이너로서, 여러 개의 데이터파일(디스크 상의 물리적인 OS 파일)로 구성된다.
테이블스페이스
테이블스페이스를 생성했으면 위와 같이 세그먼트를 생성한다. 세그먼트는 테이블, 인덱스처럼 데이터 저장공간이 필요한 오브젝트다. 테이블, 인덱스를 생성할 때 데이터를 어떤 테이블스페이스에 저장할지를 지정한다.
세그먼트는 여러 익스텐트로 구성된다. 파티션 구조가 아니라면 테이블도 하나의 세그먼트고, 인덱스도 하나의 세그먼트다. 테이블 또는 인덱스가 파티션 구조라면, 각 파티션이 하나의 세그먼트가 된다. LOB 컬럼은 그 자체가 하나의 세그먼트를 구성하므로 자신이 속한 테이블과 다른 별도 공간에 값을 저장한다.
익스텐트는 공간을 확장하는 단위이다. 테이블이나 인덱스에 데이터를 입력하다가 공간이 부족해지면 해당 오브젝트가 속한 테이블스페이스로부터 익스텐트를 추가로 할당받는다. 익스텐트는 연속된 블록들의 집합이기도 하다.
익스텐트 단위로 공간을 확장하지만, 사용자가 입력한 레코드를 실제로 저장하는 공간은 데이터 블록이다. 한 블록은 하나의 테이블만 독점한다. 즉, 한 블록에 저장된 레코드는 모두 같은 테이블 레코드다.
세그먼트 공간이 부족해지면 테이블스페이스로부터 익스텐트를 추가로 할당받는다고 했는데, 세그먼트에 할당된 모든 익스텐트가 같은 데이터파일에 위치하지 않을 수 있다.
테이블스페이스 익스텐트
익스텐트 내 블록은 서로 연속된 공간이지만, 익스텐트끼리는 연속된 공간이 아니라는 사실을 위의 그림을 통해 알 수 있다.
-- 오라클에서 세그먼트에 할당된 익스텐트 목록 조회 방법
SQL >
select segment_type, tablespace_name, extent_id, file_id, block_id, blocks
from dba_extents
and segment_name = 'MY_SEGMENT'
order by extent_id;
모든 데이터 블록은 디스크 상에서 몇 번 데이터파일의 몇 번째 블록인지를 나타내는 자신만의 고유 주소값을 갖는다. 이 주소값을'DBA(Data Block Address)'라고 부른다. 데이터를 읽고 쓰는 단위가 블록이므로 데이터를 읽으려면 먼저 DBA부터 확인해야 한다.
인덱스를 이용해 테이블 레코드를 읽을 때는 인덱스 ROWID를 이용해야한다. ROWID는 DBA + 로우 번호(블록 내 순번)로 구성되므로 이를 분해하면 읽어야 할 테이블 레코드가 저장된 DBA를 알 수 있다.
테이블을 스캔할 때는 테이블 세그먼트 헤더에 저장된 익스텐트 맵을 이용한다. 익스텐트 맵을 통해 각 익스텐트의 첫 번째 블록 DBA를 알 수 있다.
블록, 익스텐트, 세그먼트, 테이블스페이스, 데이터파일을 정의하면 다음과 같다.
블록 : 데이터를 읽고 쓰는 단위
익스텐트 : 공간을 확장하는 단위, 연속된 블록 집합
세그먼트 : 데이터 저장공간이 필요한 오브젝트(테이블, 인덱스, 파티, LOB 등)
테이블스페이스 : 세그먼트를 담는 컨테이너
데이터파일 : 디스크 상의 물리적인 OS 파일
테이블스페이스 ERD
블록 단위 I/O
데이터 I/O 단위가 블록이므로 특정 레코드 하나를 읽고 싶어도 해당 블록을 통째로 읽는다. 심지어 1Byte짜리 컬럼 하나만 읽고 싶어도 블록을 통째로 읽는다. 오라클은 기본적으로 8KB 크기의 블록을 사용하므로 1Byte를 읽기 위해 8KB를 읽는 셈이다.
-- 오라클 데이터베이스의 블록 사이즈 확인 방법.
SQL > show parameter block_size
테이블뿐만 아니라 인덱스도 블록 단위로 데이터를 읽고 쓴다.
시퀀셜 액세스 vs 랜덤 액세스
테이블 또는 인덱스 블록을 액세스하는(=읽는) 방식으로는 시퀀셜 엑세스와 랜덤 액세스, 두 가지가 있다.
첫째, 시퀀셜(Sequential) 액세스는 논리적 또는 물리적으로 연결된 순서에 따라 차례대로 블록을 읽는 방식이다. 인덱스 리프 블록은 앞뒤를 가리키는 주소값을 통해 논리적으로 서로 연결돼 있다. 이 주소 값에 따라 앞 또는 뒤로 순차적으로 스캔하는 방식이 시퀀셜 액세스다.
테이블 블록 간에는 서로 논리적인 연결고리를 가지고 있지 않다. 그럼, 테이블은 어떻게 시퀀셜 방식으로 엑세스할까?
오라클은 세그먼트에 할당된 익스텐트 목록을 세그먼트 헤더에 맵(map)으로 관리한다. 익스텐트 맵은 각 익스텐트의 첫 번째 블록 주소 값을 갖는다.
읽어야 할 익스텐트 목록을 익스텐트 맵에서 얻고, 각 익스텐트의 첫 번째 블록 뒤에 연속해서 저장된 블록은 순서대로 읽으면, 그것이 곧 Full Table Scan이다.
둘때, 랜덤(Random) 액세스는 논리적, 물리적인 순서를 따르지 않고, 레코드 하나를 읽기 위해 한 블록씩 접근(=touch)하는 방식이다.
논리적 I/O vs 물리적 I/O
DB버퍼캐시
디스크 I/O가 SQL 성능을 결정한다. SQL을 수행하는 과정에 계속해서 데이터 블록을 읽는데, 자주 읽는 블록을 매번 디스크에서 읽는 것은 매우 비효율적이다. 모든 DBMS에 데이터 캐싱 메커니즘이 필수인 이유다.
SGA
데이터를 캐싱하는 'DB버퍼캐시'도 SGA의 가장 중요한 구성요소 중 하나다. 라이브러리 캐시가 SQL과 실행계획, DB 저장형 함수/프로시저 등을 캐싱하는 '코드 캐시'라고 한다면, DB버퍼캐시는 '데이터 캐시' 라고 할 수 있다.
디스크에서 어렵게 읽은 데이터 블록을 캐싱해 둠으로써 같은 블록에 대한 반복적인 I/O Call을 줄이는 데 목적이 있다.
DB Buffer Cache
위 그림처럼 서버 프로세스와 데이터파일 사이에 버퍼캐시가 있으므로 데이터 블록을 읽을 땐 항상 버퍼캐시부터 탐색한다. 운 좋게 캐시에서 블록을 찾는다면 바쁜 시간에 프로레스가 잠(I/O Call)을 자지 않아도 된다. 버퍼캐시는 공유메모리 영역이므로 같은 블록을 읽는 다른 프로세스도 이득을 본다.
-- 오라클 SQL*Plus에서 버퍼캐시 확인.
SQL > show spa
논리적 I/O vs 물리적 I/O
논리적 블록 I/O는 SQL을 처리하는 과정에 발생한 총 블록 I/O를 말한다.
위 그림의 좌측처럼 메모리상의 버퍼 캐시를 경유하므로 메모리 I/O가 곧 논리적 I/O라고 생각해도 무방하다.
물리적 블록 I/O는 디스크에서 발생한 총 블록 I/O를 말한다. SQL 처리 도중 읽어야 할 블록을 버퍼캐시에서 찾지 못할 때만 디스크를 액세스하므로 논리적 블록 I/O 중 일부를 물리적으로 I/O 한다.
메모리 I/O는 전기적 신호인 데 반해, 디스크 I/O는 액세스 암을 통해 물리적 작용이 일어나므로 메모리 I/O에 비해 상당히 느리다. 보통 10,000배쯤 느리다.
데이터베이스 세계에서 논리적 일량과 물리적 일량을 정의해 보자. SQL을 수행하려면 데이터가 담긴 블록을 읽어야 한다. SQL이 참조하는 테이블에 데이터를 입력하거나 삭제하지 않는 상황에서 조건절에 같은 변수 값을 입력하면, 아무리 여러 번 실행해도 매번 읽는 블록 수는 같다. SQL을 수행하면서 읽은 총 블록 I/O가 논리적 I/O다.
DB 버퍼캐시에서 블록을 찾지 못해 디스크에서 읽은 블록 I/O가 물리적 I/O다. 데이터 입력이나 삭제가 없어도 물리적 I/O는 SQL을 실행할 때마다 다르다. 연속해서 실행하면 DB 버퍼캐시에서 해당 테이블 블록의 점유율이 점점 높아지기 때문이다.
버퍼캐시 히트율
Single Block I/O vs Multiblock I/O
메모리 캐시가 클수록 좋지만, 데이터를 모두 캐시에 적재할 수는 없다. 비용적인 한계, 기술적인 한계 때문에 전체 데이터 중 일부만 캐시에 적재해서 읽을 수 있다.
캐시에서 찾지 못한 데이터 블록은 I/O Call을 통해 디스크에서 DB 버퍼캐시로 적재하고서 읽는다. I/O Call을 할 때, 한 번에 한 블록씩 요청하기도 하고, 여러 블록씩 요청하기도 한다.
한 번에 한 블록씩 요청해서 메모리에 적재하는 방식을 'Single Block I/O'라고 한다. 많은 벽돌을 실어 나를 때 손수레를 이용하는 것처럼 한 번에 여러 블록씩 요청해서 메모리에 적재하는 방식을 'Multiblock I/O'라고 한다.
인덱스를 이용할 때는 기본적으로 인덱스와 테이블 블록 모두 Single Block I/O 방식을 사용한다.
인덱스 루트 블록을 읽을 때
인덱스 루트 블록에서 얻은 주소 정보로 브랜치 블록을 읽을 때
인덱스 브랜치 블록에서 얻은 주소 정보로 리프 블록을 읽을 때
인덱스 리프 블록에서 얻은 주소 정보로 테이블 블록을 읽을 때
반대로, 많은 데이터 블록을 읽을 때는 Multiblock I/O 방식이 효율적이다. 그래서 인덱스를 이용하지 않고 테이블 전체를 스캔할 때 이 방식을 사용한다. 테이블이 클수록 Multiblock I/O 단위도 크면 좋다. 프로세스가 잠자는 횟수를 줄여주는 데 이유가 있다.
Table Full Scan vs Index Range Scan
테이블에 저장된 데이터를 읽는 방식은 두 가지다.
Table Full Scan은 말 그대로 테이블에 속한 블록 '전체'를 읽어서 사용자가 원하는 데이터를 찾는 방식이다. 인덱스를 이용한 테이블 액세스는 인덱스에서 '일정량'을 스캔하면서 얻은 ROWID로 테이블 레코드를 찾아가는 방식이다. ROWID는 테이블 레코드가 디스크 상에 어디 저장됐는지를 가리키는 위치 정보다.
인덱스를 이용하는데 성능이 느린 경우는 왜그럴까?
시퀀셜 액세스와 랜덤 액세스, Single Block I/O와 Multiblock I/O 등등 I/O 메커니즘 관점에서 Table Full Scan과 Index Range Scan의 본질을 알아보자.
Table Full Scan은 시퀀셜 액세스와 Multiblock I/O 방식으로 디스크 블록을 읽는다. 한 블록에 속한 모든 레코드를 한 번에 읽어 들이고, 캐시에서 못 찾으면 '한 번의 수면(I/O Call)을 통해 인접한 수십~수백 개 블록을 한꺼번에 I/O하는 메커니즘'이다. 이 방식을 사용하는 SQL은 스토리지 스캔 성능이 좋아지는 만큼 성능도 빨라진다.
큰 테이블에서 소량 데이터를 검색할때는 반드시 인덱스를 이용해야 한다.
Index Range Scan을 통한 테이블 액세스는 랜덤 액세스와 Single Block I/O 방식으로 디스크 블록을 읽는다. 캐시에서 블록을 못 찾으면, '레코드를 찾기 위해 매번 잠을 자는 I/O 메커니즘'이다. 따라서 많은 데이터를 읽을 때는 Table Full Scan보다 불리하다. 읽을 데이터가 일정량을 넘으면 인덱스보다 Table Full Scan이 유리하다.
SQL 파싱, 최적화, 로우 소스 생성 과정을 거쳐 생성한 내부 프로시저를 반복 재사용할 수 있도록 캐싱해 두는 메모리 공간을 '라이브러리 캐시(Libray Cache)'라고 한다. 라이브러리 캐시는 SGA 구성요소다. SGA(System Global Area)는 서버 프로세스와 백그라운드 프로세스가 공통으로 액세스하는 데이터와 제어 구조를 캐싱하는 메모리 공간이다.
SGA
사용자가 SQL문을 전달하면 DBMS는 SQL을 파싱한 후 해당 SQL이 라이브러리 캐시에 존재하는지부터 확인한다. 캐시에서 찾으면 곧바로 실행 단계로 넘어가지만, 찾지 못하면 최적화 단계를 거친다. SQL을 캐시에서 찾아 곧바로 실행단계로 넘어가는 것을 '소프트 파싱(Soft Parsing)'이라 하고, 찾는 데 실패해 최적화 및 로우 소스 생성 단계까지 모두 거치는 것을 '하드 파싱(Hard Parsing)'이라고 한다.
SQL 최적화 과정을 왜 하드(Hard)할까?
옵티마이저가 SQL을 최적화할 때 데이터베이스 사용자들이 보통 생각하는 것보다 훨씬 많은 일을 수행한다. 다섯 개 테이블을 조인하는 쿼리문 하나를 최적화하는 데도 무수히 많은 경우의 수가 존대한다. 조인 순서만 고려해도 120가지다. 여기서 NL 조인, 소트 머지 조인, 해시 조인 등 다양한 조인 방식이 있다. 테이블 전체를 스캔할지, 인데스를 이용할지 결정해야 하고, 인덱스 스캔에도 Index Range Scan, Index Unique Scan, Index Full Scan 등 다양한 방식이 제공된다. 이렇게 SQL 옵티마이저는 순식간에 엄청나게 많은 연산을 한다. 그 과정에서 옵티마이저가 사용하는 정보는 다음과 같다.
테이블, 컬럼, 인덱스 구조에 관한 기본 정보
오브젝트 통계 : 테이블 통계, 인덱스 통계. (히스토그램을 포함한) 컬럼 통계
시스템 통계 : CPU 속도, Single Block I/O 속도, Multiblock I/O 속도 등
옵티마이저 관련 파라미터
이렇게 어려운 작업을 거쳐 생성한 내부 프로시저를 한 번만 사용하고 버린다면 엄청난 비효율일 것이다. 라이브러리 캐시가 필요한 이유가 바로 여기에 있다.