제 1 주 PHP 하십니까?
제 2 주 PHP를 이용한 개발기간의 단축
제 3 주 PHP와 오라클을 이용한 엔터프라이즈 애플리케이션 개발
제 4 주 PHP, 어른이 되다
제 5 주 오라클/PHP 환경의 확장
제 6 주 PHP 5 Data Object (PDO) Abstraction Layer와 오라클
제 7 주 PHP 5, 오라클, 그리고 미래

히치하이커를 위한 PHP 가이드 ⑤

오라클/PHP 환경의 확장

George Schlossnagle│OmniTI Computer Consulting 수석 컨설턴트

관리와 확장이 용이하고, 성능 면에서 뛰어난 PHP 코드를 오라클 데이타베이스 환경에서 설계/작성하는 테크닉을 설명합니다.

지난 9년 동안, PHP는 개인용 웹 사이트의 편집을 위한 “틈새” 언어에서 세계 최대 규모의 사이트를 지원하는 강력한 언어 환경으로 발전하였습니다. 대량의 트래픽이 발생하는 웹 사이트의 설계 요구사항은 성능, 확장성, 그리고 유지보수성(maintainability)의 세 단어로 요약됩니다. 확장성이란, 애플리케이션의 트래픽 로드가 증가하는 환경에서 기본 설계의 변경 없이 지속적인 성장이 가능해야 함을 의미합니다. 성능이란, 개별 사용자의 요청을 신속하게 처리할 수 있는 능력을 말합니다. 유지보수성이란 많은 수고를 들이지 않고도 애플리케이션을 수정, 보수, 보완할 수 있음을 의미합니다.

PHP를 이용하여 이와 같은 세 가지 설계 목표를 달성하는 것은 그리 어렵지 않습니다. 하지만 애플리케이션의 구조를 어떻게 정의하고 어떻게 구축할 것인지 미리 고민하는 과정이 선행되어야 합니다. 성능, 확장성, 유지보수성을 갖춘 PHP 코드를 작성하는 문제는 매우 광범위한 주제입니다. 이에 관련한 많은 테크닉이 존재하며, 세 가지 요구사항을 해결하는 방법을 설명하는 많은 문서들이 제공되고 있습니다. 본 문서에서는, 오라클/PHP환경에 직접적으로 관련된 문제에 초점을 맞추면서, 몇 가지 일반적인 테크닉과 설계 고려사항을 소개합니다.

성능 문제에 관한 논의를 시작하기 전에 한 가지 주의를 당부 드리고자 합니다. 성능 면에서 아무리 뛰어나다 하더라도, 불완전하게 작성된 애플리케이션은 무용지물에 불과합니다. 애플리케이션 일관성의 희생을 감수하면서까지 성능 튜닝을 수행하려는 유혹을 받기 쉬운 것이 사실입니다. 웹은 그 성격 상, 잦은 업그레이드가 큰 문제가 되지 않는 특성을 갖습니다 (웹 사이트의 “새로운 버전”을 공개하는 과정은 그리 많은 비용을 수반하지 않습니다). 따라서 꼭 필요한 경우가 아니라면 애플리케이션에 심각한 영향을 줄 수 있는 튜닝 작업은 뒤로 미룰 수 있습니다. 개발자는 쉽게 수정 가능한 코드를 작성하는 것을 우선적인 목표로 하여 작업에 임해야 합니다.

연결의 생성 및 관리

오라클 데이타베이스와의 연결은 성능 면에서 매우 중요합니다. 데이타베이스 연결이 애플리케이션의 성능과 확장성에 어떤 영향을 미치는지 이해하려면, 먼저 데이타베이스 연결에 수반되는 과정을 이해할 필요가 있습니다. ( 그림 1 참고) 각 단계별로 수행되는 작업이 다음과 같습니다:

클라이언트 연결 생성: 클라이언트에서 오라클 리스너(listener)에 대해 네트워크 연결을 생성하고, 인증 정보를 제공한 후 세션을 요청합니다.

서버 세션 생성: 인증 작업이 완료되면, 서버는 클라이언트를 위한 새로운 세션을 생성합니다. (성능 및 확장성 면에서 많은 문제가 제기되고 있는) Oracle Multi-Threaded Server(MTS)를 이용하여 세션을 공유하는 경우가 아니라면, 서버는 세션에 별도의 프로세스를 할당하게 됩니다. 이 프로세스를 “쉐도우 프로세스(shadow process)”라 부르기도 합니다. 쉐도우 프로세스를 생성하는 과정은 꽤 복잡합니다. 쉐도우 프로세스를 생성하는 과정에서는, 프로세스 생성에 일반적으로 수반되는 오버헤드 이외에도 생성 과정에서 일부 시스템 공유 리소스에 락을 걸어야 합니다.

클라이언트 쿼리 실행: 연결 설정이 완료되면, 클라이언트가 쿼리를 실행합니다.

클라이언트 연결 종료: 클라이언트의 작업이 완료되면, 서버에 대한 연결이 종료(close) 됩니다.

서버 세션 종료: 사용자 세션과 연관된 쉐도우 프로세스가 종료되며, 커밋되지 않은 트랜잭션은 롤백 됩니다.


그림 1: 데이타베이스 연결 과정

새로운 쉐도우 프로세스를 생성하는 비용이 만만치 않기 때문에, 가능한 한 쉐도우 프로세스의 생성 작업을 피하는 것이 좋습니다. 가장 간단한 방법으로 “persistent” 연결을 사용하는 방법이 있습니다. PHP는 기본적으로 “stateless” 언어입니다. 데이타베이스에 대한 요청 과정에서 생성된 모든 정보는, 요청이 완료된 이후 자동으로 삭제됩니다. 따라서 오라클 클라이언트 연결에서는 이와 같은 방식으로 동작하지 않도록 별도의 조치를 취할 필요가 있습니다.

여러 요청에 대해 지속적으로 서비스를 제공하는 “persistence” 연결을 구현하려면, 아래 두 가지 연결 방식 중 하나를 사용해야 합니다:

OCIPLogin($username, $password [, $tnsname])

또는

OCINLogin($username, $password [, $tnsname])

위 두 가지 함수 모두 persistence 연결을 생성한다는 점에서는 동일합니다. 단 OCINLogin()의 경우 각 요청 별로 새로운 세션 핸들(session handle)을 생성한다는 점에서 차이를 갖습니다. 애플리케이션이 트랜잭션을 사용하는 환경, 특히 동시 실행되는 트랜잭션들을 여러 개의 세션으로 분리 실행하고자 하는 환경에서 OCINLogin()을 사용할 수 있습니다.

persistence 연결의 단점으로 “프로세스 스타베이션(process starvation)”의 가능성이 높아진다는 점을 지적할 수 있습니다. (최대 256개의 child process를 디폴트로 지원하는) 단일 Apache Web 서버와 단일 오라클 데이타베이스를 사용하는 경우에는 이와 같은 문제가 발생할 가능성이 거의 없습니다. 하지만 웹 서버 수가 4개로 늘어난다면, 오라클 데이타베이스에 대한 persistent 연결의 수는 1,024 개(256x4)로 증가하며, 조만간 오라클 인스턴스의 리소스가 한계 상황에 도달하게 될 것입니다.

오라클 인스턴스의 설정 파일(init.ora)에서 튜닝 가능한 매개변수가 두 가지 있습니다:

sessions = NNNN and processes = NNNN.

이 두 가지 매개변수는 인스턴스가 지원하는 최대 세션 수와 최대 프로세스 수를 정의하는데 사용됩니다. 1,204 개의 동시 연결을 지원해야 한다면, sessions 변수를 1,024 이상의 값으로 설정해 주어야 합니다 (OCINLogin() 연결에서 실행되는 일부 recursive 쿼리의 경우 하나의 연결을 통해 여러 개의 세션을 사용할 수도 있음을 감안해야 합니다). 최대 프로세스 수는 이보다도 더 높게 설정해야 합니다 (Oracle 백그라운드 프로세스 역시 계산에 포함시켜야 합니다). 하지만 이 변수들을 지나치게 높은 값으로 설정하는 것은 바람직하지 않습니다. 오라클 프로세스의 메모리 사용량은 결코 무시할 수 없는 수준(대부분 시스템의 경우 2~3 MB)입니다. 이것은 개별적으로 보았을 때는 작은 수치라고 생각할 수 있습니다. 하지만 SGA가 요구하는 메모리 영역과 합산해서 본다면, 데이타베이스 서버의 물리적 메모리가 부족해지는 주된 원인으로 작용할 수도 있습니다.

MySQL 환경에 익숙한 사용자들은, persistence 연결의 사용을 최대한 자제하려는 경향을 보입니다 (이것은 MySQL 환경의 기본적인 요구사항이기도 합니다). 하지만 오라클 쉐도우 프로세스의 생성 과정에서 발생하는 래치(latch) 및 파일 경합 현상을 감안했을 때, “non-persistence” 연결을 사용하는 것은 매우 비효율적입니다. 그렇다면 해법은 무엇일까요?

• 데이타베이스가 감당할 수 있는 최대 동시 세션 수를 확인하고, 그 결과에 따라 매개변수를 설정합니다. 최대 동시 세션 수의 계산 방법을 설명한 아티클들이 따로 있지만, 간단한 산수만으로도 그 결과를 얻을 수 있습니다. 먼저 시스템의 물리적 메모리 전체 용량에서 커널, 기타 지원 프로그램, 오라클 백그라운드 프로세스 등이 사용하는 메모리 용량을 제합니다. 그 다음에는 오라클에 의해 공유 메모리(공유 풀, 버퍼 캐시 등)로 지정된 메모리 영역을 제합니다. 이렇게 해서 남은 수치가 바로 쉐도우 프로세스가 사용할 수 있는 메모리 용량이 됩니다. 이 값을 현재 실행 중인 쉐도우 프로세스가 사용 중인 평균 프로세스 메모리 영역으로 나눕니다 (쉐도우 프로세스의 메모리 사용량은 쿼리의 성격에 따라 달라질 수 있습니다). 이렇게 얻어진 값을 동시 실행 가능한 프로세스 수로 볼 수 있습니다.

• 설정된 최대 프로세스 수 이상의 프로세스가 생성되지 않도록 웹 서버를 설정합니다. 이를 위해 각 웹 서버의 MaxChildren 매개변수를 수정하여, 모든 웹 서버의 프로세스 수를 합산하더라도 오라클이 지원하는 프로세스 수를 초과하지 않도록 해야 합니다. (따라서 Apache 인스턴스 당 지원하는 프로세스의 수는 256개보다 작아지게 됩니다.)

• 웹 서버의 child process 자원이 불필요하게 낭비되지 않도록 애플리케이션의 구조를 재정비합니다. 이 부분에 대해서는 뒤에서 자세하게 설명하겠습니다.

이러한 작업이 과연 중요할까요? 한 고객 환경에서, 우리는 persistence 연결이 활성화 되어 있음에도 불구하고 래치 경합 (latch contention) 문제가 지속적으로 발생하는 상황을 경험했습니다. 문제를 겪어 본 사용자라면 누구나 잘 알고 있겠지만, 래치 경합으로 인해 락의 지연이 심각해 지는 경우 서버는 무응답 상태로 빠지게 됩니다. 문제의 원인을 분석하는 과정에서, 대부분의 프로세스가 종료되기까지 수백 개에 달하는 요청을 처리하고 있는 데 반해, 일부 프로세스가 단 하나의 요청만을 처리하는데 사용되고 있음을 발견했습니다. Apache의 MaxSpareServers 매개변수를 지나치게 낮게 설정한 것이 문제의 원인이었습니다. 로드 밸런싱 장비의 내재적인 문제로 인해 각 웹 서버의 부하가 집중적이고 간헐적인 형태로 발생되고 있었고, 웹 서버는 한꺼번에 많은 수의 요청을 처리한 후 다시 대기 상태로 빠지는 과정을 반복하고 있었습니다. Apache 서버는 집중적으로 발생하는 요청을 처리하기 위해 추가로 child process를 생성하고, 요청의 수가 줄어들면 다시 프로세스들을 종료시키는 비효율적인 작업을 반복하였습니다. 전체적으로 보았을 때 이것은 non-persistent 프로세스를 실행하는 것과 같은 효과를 초래했습니다. MaxSpareServers 매개변수를 더 높은 값으로 설정한 후에는 이러한 문제가 해결되었고 래치 경합 문제도 사라졌습니다.

SQL의 실행

오라클 기반 클라이언트/서버 환경의 핵심에는 쿼리의 실행이 있습니다. 쿼리의 튜닝이라는 방대한 주제를 논의하기에는 이 문서의 지면이 부족할 것입니다. 그 대신 이미 튜닝이 완료된 쿼리를 최대한 효율적으로 실행하는 방법에 대해 알아보기로 합니다.

PHP에서 효율적인 오라클 애플리케이션 코드를 작성하는 첫 번째 단계는 항상 바인드 SQL(bind SQL)을 활용하는 것입니다. 아래와 같은 쿼리가 필요한 경우를 가정해 봅시다:

SELECT * FROM USERS WHERE USERNAME = 'george'

오라클은 소프트-파싱(soft-parsing)을 통해 위 쿼리가 이전에 컴파일된 적이 있는지 확인합니다. 디폴트 환경에서, ‘george’라는 값은 쿼리에 기본적으로 포함된 문자열로 가정됩니다. 따라서 다른 이름(‘bob’)을 사용하는 경우, 오라클은 전혀 다른 쿼리로 인식하게 됩니다. 오라클은 실행된 쿼리의 파싱 결과를 공유 풀(shared pool)에 저장합니다. 따라서 1000 가지의 이름으로 쿼리를 실행했다면, 공유 풀에는 서로 다른 1000 종류의 파싱 결과가 저장될 것입니다. 트래픽 부하가 그리 높지 않은 환경이라 하더라도, 이로 인해 메모리 단편화(fragmentation) 현상이 발생하고, ORA-4021 에러가 빈번해 질 수 있습니다.

이러한 문제를 해결하기 위해 바인드 SQL을 사용할 수 있습니다. 바인드 SQL을 사용하는 경우, WHERE 절에 사용된 문자열은 아래와 같이 별도의 “placeholder”로 대치됩니다:

SELECT * FROM USERS WHERE USERNAME = :NAME

따라서 쿼리의 하드-파싱(hard-parsing)이 단 한 차례만 수행됩니다. 후속되는 파싱 작업은 소프트-파싱(soft-parsing) 형태로만 실행되며, 이때 데이타베이스 엔진은 SGA로부터 이미 컴파일된 쿼리를 가져오는 작업만을 수행합니다. 파싱 결과 역시 공유 풀에 단 한 차례만 저장되므로 메모리 사용량을 극적으로 절감할 수 있습니다.

바인드 쿼리를 사용한 실행 예가 다음과 같습니다:

< ?php $db = OCIPLogin('scott', 'tiger', 'testdb'); $stmt = OCIParse($db, 'SELECT * FROM USERS WHERE USERNAME = :NAME'); $name = 'george'; OCIBindByName($stmt, ':NAME', $name, -1); OCIExecute($stmt); ?>

Oracle8i가 발표되기 전까지, 바인드 쿼리는 수동으로만 수행 가능했습니다. Oracle8i부터는 ini.ora 파일에서 'cursor_sharing = FORCE'로 설정한 경우, 옵티마이저가 바인딩 대상이 되는 문자열 값을 확인하고 자동으로 바인드 쿼리를 실행합니다. 9i부터 지원되는 'cursor_sharing = SIMILAR' 설정의 경우, 옵티마이저가 테이블 통계정보를 조회하여 문자열에 대한 자동 바인딩(autobinding)이 효과가 있을 것인지를 먼저 예측합니다 (필드 값의 분포가 비대칭적(skewed)인 경우에는 효과가 없을 수도 있습니다). 이 두 가지 세팅(8i의 FORCE, 9i의SIMILAR in 9i)은 기본적으로 활성화되어 있어야 하지만, 옵티마이저가 바인딩 여부를 분석하는 작업에는 많은 비용이 들 수 있으므로 가능한 한 수동으로 바인딩을 적용하는 것이 좋습니다.

오라클 클라이언스/서버 환경에서 사용되는 SQLNet 프로토콜은 수다스럽기로 악명이 높습니다. 100개의 로우(row)를 반환하는 쿼리를 수행하는 경우, SQLNet 프로토콜은 쿼리의 파싱, 바인드 변수의 교환, 실행될 쿼리의 전달, 로우 단위 쿼리 전달 등의 과정에서 매우 빈번하게 서버와 데이타 교환을 수행합니다. 각각의 데이타 교환이 수행될 때마다 클라이언트와 서버 간에 네트워크 패킷이 교환되며(이 과정을 라운드트립(round-trip)이라 합니다), 라운드트립의 수를 줄임으로써 성능을 대폭적으로 개선하는 것이 가능합니다.

이 문제를 해결하기 위한 첫 번째 방법은 클라이언트의 pre-fetch buffer를 설정하는 것입니다. 네트워크를 통해 개별 로우(row)를 하나씩 가져오는 것은 매우 비효율적입니다. 따라서 데이타를 서버의 로컬 버퍼에 충분히 저장한 다음 한꺼번에 가져오는 방법을 사용하는 것이 좋습니다. 다음과 같이 statement handle을 설정하는 경우, 오라클 클라이언트 라이브러리는 자동적으로 버퍼를 활용한 커뮤니케이션을 수행합니다:

$stmt = OCIParse($db, $query); OCISetPreFetch($stmt, 1000); // execute and fetch

위 설정은 두 개 이상의 로우를 반환하는 쿼리가 수행되는 경우 OCI 클라이언트 라이브러리가 한꺼번에 1,000 개의 로우를 내부적으로 버퍼링한 후 한꺼번에 전달하도록 지시하고 있습니다. 여기서 1,000이라는 숫자는 임의로 설정한 것입니다. pre-fetch buffer의 사이즈를 설정할 때 다음 두 가지를 주의해야 합니다:

1. 버퍼가 완전히 차기 전까지는 실행 결과가 반환되지 않습니다. 따라서 부분적인 결과 셋을 미리 확인해야 하는 경우라면 버퍼 사이즈를 지나치게 크게 잡지 않는 것이 좋습니다.

2. 버퍼링된 결과 셋은 로컬 메모리에 저장됩니다. 매우 많은 로우 또는 매우 큰 결과 셋이 반환되는 경우, 버퍼 사이즈를 지나치게 높게 설정하지 않는 것이 좋습니다. 자칫하면 메모리 부족 현상이 발생할 수도 있습니다.

오라클과의 커뮤니케이션 관리

지금까지 PHP과 오라클의 커뮤니케이션에 관련한 몇 가지 테크닉을 소개했습니다. 관리 상의 관점에서 볼 때 이와 같은 구조적 변경 사항을 적용하는 것은 무척 번거로운 일이 될 수 있으며, 대개의 경우 코드를 상당 부분 수정하는 수고를 감수해야 합니다. 따라서 데이타베이스 액세스 코드를 “wrapper” 라이브러리에 인캡슐레이트(encapsulate) 함으로써, 쿼리의 준비 및 실행 과정에 관련한 변경 사항이 발생하더라도 전체 코드를 수정할 필요가 없도록 하는 것이 바람직합니다.

여기서 필자는 “추상화 계층(abstraction layer)” 대신 “wrapper 라이브러리”라는 용어를 사용했습니다. 추상화 계층은 일반적으로 하위 레벨 데이타베이스 호출 과정 뿐 아니라 SQL 구문에 대해서도 “추상화(abstraction)” 효과를 제공합니다. 추상화 계층은 데이타베이스 중립적인 문법(syntax)를 사용하므로, 데이타베이스가 변경되더라도 애플리케이션을 수정할 필요가 없다는 장점이 있습니다. 하지만 이러한 접근법에는 세 가지 중요한 문제가 있습니다:

SQL은 강력한 서술형 언어로서, 개발자들의 이해도가 높은 편입니다. 개발자들로부터 SQL 구문을 숨기고, 그 대신 SQL보다 유연성이 떨어지는 새로운 언어를 배우도록 강요하는 것은 어리석은 일입니다.

모든 데이타베이스 제품들이 특정 과제 수행을 위한 비표준형 SQL 구문을 지원합니다. 이러한 구문의 활용 가능성을 강제적으로 배제하는 것은 유연성을 제약하는 결과를 가져오며, 사용자가 선택한 데이타베이스 플랫폼을 효과적으로 활용할 수 없게 만듭니다. 추상화 계층은 모든 데이타베이스 제품이 공통적으로 제공하는 기능만을 지원하며, 따라서 사용자의 선택의 폭은 매우 제한될 수 밖에 없습니다.

사용자가 데이타베이스 벤더를 교체하는 경우는 매우 드물며, 애플리케이션이 어떻게 작성되었든 데이타 마이그레이션에 상당한 시간과 비용을 들이는 것이 불가피합니다.

PHP 환경에서 가장 널리 사용되는 데이타베이스 wrapper/abstraction 라이브러리로 PEAR::DB (http://pear.php.net/)와 ADODB (http://php.weblogs.com/adodb)가 있습니다. 이 두 가지 라이브러리가 인기도 및 완성도 면에서 매우 뛰어난 것은 사실이지만, 필자는 개인적으로 데이타베이스 wrapper 라이브러리를 직접 개발하는 방법을 선호합니다. 필자는 ADODB와 PEAR::DB가 제공하는 고급 기능을 활용할 필요성을 별로 느끼지 못하며, 라이브러리를 단순화하는 것이 관리편의성 면에서 훨씬 낫다고 생각합니다. 복잡한 기능을 사용하지 않는다면, 약 100 라인의 코드만으로 라이브러리를 완성할 수도 있습니다. 오라클 데이타베이스와 statement handle에서 사용되는 정보의 양을 감안했을 때, 필자는 object-oriented wrapper가 더 바람직하다고 봅니다. 하지만 procedural wrapper를 효과적으로 이용한 사례를 여러 차례 확인하기도 했습니다.

그림 1 은 완전한 구성을 갖춘 wrapper 라이브러리 예제입니다. 이 라이브러리에는 데이타베이스 연결을 위한 wrapper(DB_Oracle)와 커서를 위한 wrapper(DB_OracleStatement)를 포함하고 있습니다.

이 라이브러리가 제공하는 클래스들을 이용하여 데이타베이스와의 커뮤니케이션 과정을 보다 단순하고 효과적이고 깔끔한 형태로 다듬을 수 있습니다. 간단한 쿼리 실행 과정에서 라이브러리의 활용 예가 아래와 같습니다:

< ?php include_once("DB_Oracle.inc"); $dbh =& new DB_Oracle('scott', 'tiger', 'testdb'); $stmt =& $dbh->prepare("SELECT * FROM users WHERE name = :name"); $stmt->execute(array(':name' => 'george')); $result = $stmt->fetch(); // ... ?>

wrapper가 객체지향형 구조로 작성되었기 때문에, 클래스의 확장을 통해 모든 연결 매개변수를 숨기는 것이 가능합니다. 아래와 같이 매개변수를 전혀 사용하지 않는, 매우 단순한 형태의 연결 클래스를 구현할 수 있습니다:

class DB_Oracle_Test extends DB_Oracle { var $user = "scott"; var $pass = "tiger"; var $tnsname = "testdb"; function DB_Oracle_Test() {} }

위 클래스는 연결 과정에서 필요한 모든 매개변수를 개발자로부터 숨기는 효과를 제공하며, 따라서 개발자는 TNSNAME을 비롯한 연결 정보를 명시하지 않아도 됩니다. 위 예제에서 OCISetPreFetch()호출 과정이 개발자로부터 숨겨지고 있음을 주목하시기 바랍니다. 이 부분을 제거하거나, 다른 변경이 필요한 경우에도 아주 간단한 작업만으로 전체 연결에 대해 변경 사항을 적용할 수 있습니다. 이것이 wrapper 라이브러리가 유용한 이유입니다.

프로세스의 효율성 개선

이 문서의 앞부분에서, 필자는 Apache에서 사용되는 child process의 수를 최소화함으로써 확장성을 개선할 수 있다고 말한 바 있습니다. 최소한의 리소스를 활용하여 이러한 목표를 달성할 수 있는 세 가지 방법이 아래와 같습니다:

태스크의 수행 시간을 최소화. 당연한 얘기겠지만, 처리 성능을 극대화하는 가장 좋은 방법은 개별 작업이 최단시간 내에 수행될 수 있도록 하는 것입니다. 컴파일러 캐시를 설치하거나, 코드에 대한 프로파일링을 수행하거나, 데이타베이스 쿼리를 튜닝하거나 하는 다양한 방법을 동원함으로써 확장성을 개선할 수 있습니다.

일부 태스크를 별도 서버에 할당. 웹 사이트는 다이내믹 컴포넌트(PHP 스크립트 등)와 정적 컴포넌트(이미지, HTML 코드 등)로 구성됩니다. 오라클 데이타베이스의 액세스를 전담하는 Apache 인스턴스로 하여금 정적 컨텐트까지 제공하게 하는 것은 낭비입니다. 정적 컨텐트를 담당하는 별도의 웹 서버를 설계하여 부하를 분산하도록 합니다.

생략할 수 있는 작업요소가 있는지 확인. 같은 내용의 데이타를 데이타베이스에 대한 반복적인 쿼리를 통해 매번 새롭게 생성하는 것도 낭비 요소입니다. 이러한 부분이 있는지 분석하고, 가능한 한 정적인 데이타를 활용함으로써 성능과 확장성을 극대화할 수 있습니다.

정적 컨텐트의 오프로딩. 웹 애플리케이션의 각 페이지 별로 사용되는 평균 이미지 수가 9개라면, 웹 서버에 할당된 persistence 연결이 실제로 사용되는 비율은 불과 10 퍼센트에 불과할 것입니다. 다시 말해, 90퍼센트의 시간 동안은 귀중한 오라클 connection handle 자원이 낭비되고 있는 셈입니다. 따라서, 오라클 연결(또는 다이내믹 컨텐트)을 실제로 필요로 하는 요청에 대해서만 다이내믹 웹 서버가 서비스를 제공하도록 제한하는 작업이 필요합니다. 이렇게 함으로써 각 프로세스가 오라클 관련 작업을 수행하는 비중을 높일 수 있고, 결과적으로 다이내믹 컨텐트를 생성하는 데 필요한 child 프로세스의 수가 그만큼 줄게 됩니다.

이와 같이 하기 위한 가장 간단한 방법은, 별도의 웹 서버에 모든 이미지를 오프로드 하는 것입니다. 이 방법은 놀랄 만큼 간단합니다. 먼저 정적 요청을 처리할 별도의 웹 서버를 셋업합니다. 이 서버에 Apache를 사용할 수도 있겠지만, 정적 데이타의 서비스를 위해 특별히 설계된 웹 서버(tux, thttpd 등)를 사용하는 것이 더 좋을 것입니다. 이 웹 서버가 별도의 서브 도메인을 사용하도록 설정해야 할 수도 있습니다. 가장 일반적인 방법은 "www.example.com" 대신 "images.example.com"과 같은 도메인을 사용하는 것입니다. 일부 로드 밸런싱 하드웨어의 경우, 같은 도메인을 갖는 이미지들을 별도의 웹 서버에서 가져오도록 설정할 수도 있습니다.

도메인 설정 과정이 완료되었다면, 글로벌 configuration 파일을 생성하여, 애플리케이션에서 사용하는 글로벌 상수들을 정의합니다.
이 때 다음과 같은 정의가 포함되어야 합니다:

define(CDN_URL, "http://images.example.com");

configuration 파일을 모든 파일의 상위 영역에 수동으로 포함시킬 수도 있고, php.ini 파일에 다음과 같은 라인을 추가함으로써 모든 스크립트를 수행할 때 자동으로 실행되도록 할 수도 있습니다:

auto_prepend_file = /path/to/config.inc

이제 HTML에 이미지 태그를 생성할 때마다, 다음과 같은 코드가 자동으로 추가됩니다:

<img src="<?= CDN_URL ?>/path/to/image.png">

tag-writing 라이브러리를 별도로 관리하는 경우, 이미지 태그 생성 함수를 다음과 같이 정의할 수도 있습니다:

function img_tag($local_uri, $attr) { $attribute = ''; foreach ($attr as $k=>$v) { $k = urlencode($k); $v = urlencode($v); $attribute .= " $k=\"$v\" "; } return "<img src=\"".CDN_URL."$local_uri\" $attribute>"; }

당장 이미지를 위해 별도의 서버를 할당할 계획이 없다 해도, 애플리케이션 코드 상에서 이미지를 위한 별도의 베이스 경로를 할당해주고 아래와 같은 코드를 추가해 둘 필요가 있습니다:

define(CDN_URL, "http://www.example.com/images");

이렇게 하면 나중에 한 라인의 코드만 변경함으로써 전체 이미지를 별도 서버에서 제공하도록 변경할 수 있습니다. 정적 컨텐트/이미지의 비율이 높은 경우, 서버 리소스가 극적으로 절감되는 효과를 확인하실 수 있을 것입니다. 한 클라이언트의 경우, Apache에서 제공되던 정적 컨텐트를 thttpd 서버로 오프로드 처리한 결과 전체 인프라스트럭처 비용을 50%나 절감한 사례가 있습니다.

불필요한 작업 요소의 제거. 가장 확실한 성능 개선 방법은 쿼리가 아예 실행되지 않도록 하는 것입니다. 다이내믹한 웹 페이지라 하더라도 일정 시간 동안은 정적인 상태를 유지하는 것이 일반적입니다. 뉴스 사이트를 생각해 봅시다. 새로운 뉴스 아이템이 업데이트될 때까지 컨텐트는 변하지 않습니다. 업데이트가 분 단위로 수행되든 시간 단위로 수행되든, 업데이트 주기가 돌아올 때까지 사이트는 정적인 상태를 유지합니다. 따라서 페이지를 요청할 때마다 데이타베이스를 조회하는 대신, 업데이트 사이클이 돌아오는 시점에 단 한 차례만 데이타베이스 조회를 수행하도록 할 수 있을 것입니다.

캐시의 유형 중 가장 간단한 형태로 “full-page on-demand” 캐시가 있습니다. 이 캐시가 사용되는 경우, 애플리케이션은 요청에 대해 해당 파일이 캐시에 존재하는지 확인합니다. 캐시에 복사본이 존재한다면, 그 내용이 요청자에게 반환됩니다. 그렇지 않다면, 새로운 캐시 복사본이 생성됩니다. 캐시가 리프레시 되면 이전 버전의 캐시 복사본은 삭제되며, 나중에 다른 요청이 있을 때까지 복사본은 생성되지 않습니다.

그림 2 는 이와 관련한 플로우 다이어그램을 보여주고 있습니다. “/archive/123.html” 파일에 대한 요청이 접수되면, 웹 서버는 해당 파일이 캐시에 존재하는지 확인합니다. 캐시에 파일이 있다면, 그 파일이 반환됩니다. 그렇지 않다면, 사용자는 PHP 페이지(“generate.php")로 리다이렉트 됩니다 (이때 페이지 식별자 ‘123’이 매개변수로 전달됩니다). PHP 페이지는 해당 페이지의 캐시 엔트리를 생성하게 됩니다. Apache 환경에서 이와 같은 작업을 수행하기 위해서는 ErrorHandler 또는 mod_rewrite가 사용됩니다. mode_rewrite가 유연성 면에서 더 뛰어나므로, 여기에서는 mode_rewrite를 사용하기로 하겠습니다.


그림 2: Full-Page On-Demand Cache 플로우 다이어그램

먼저, httpd.conf 파일 내에 rewrite rule을 설정합니다. 그 예가 다음과 같습니다:

RewriteEngine On RewriteConf %{REQUEST_FILENAME} ^/archive/[0-9]+\.html RewriteConf %{REQUEST_FILENAME} !-f RewriteRule ^/archive/([0-9]+)\.html /generate.php?id=$1

위 코드는 rewriting 엔진을 활성화하고, 아카이브 패턴(^/archive/[0-9]+\.html)에 일치하는 이름을 갖는 파일이 존재하지 않는 경우 (!-f), 요청된 파일이 페이지 식별자를 매개변수로 하는 다른 요청을 통해 generation 페이지에 전달되도록 정의하고 있습니다.

generator 페이지의 구성은 비교적 간단합니다:

<?php $id = $_GET['id']; $dbh =& new DB_Oracle_TestDB; $cursor =& $dbh->execute("SELECT content FROM news WHERE id = $id"); $result = $cursor->fetch(); if(!$result) { header("HTTP/1.0 404 Not Found"); exit; } else { echo $result['CONTENT']; $outfile = $_SERVER['DOCUMENT_ROOT']."/archive/$id.html"; if(($fp = fopen($outfile, "w")) === false) { exit; } fwrite($fp, $result['CONTENT']); fclose($fp); } ?>

이 generator 코드는 news 테이블의 content 컬럼이 완전한 포맷을 갖추고 있다고 가정하고 있습니다. 실제 환경에서는 output buffering을 통해 출력을 캡처하고, 포맷 작업을 수행하는 부분이 스크립트에 추가되어야 할 것입니다.

결론

지금까지 오라클/PHP 환경을 확장하기 위한 몇 가지 테크닉을 살펴 보았습니다. 이 문서에서 소개된 예제들이 유용하게 활용될 수 있기를 바랍니다. 마지막으로 이 문서에서 얻을 수 있는 중요한 교훈 몇 가지를 간추려 보겠습니다:

• 오라클 연결을 주의 깊게 관리하고 리소스 부족 현상이 발생하지 않도록 예방합니다.
• 태스크의 실행 속도를 개선함으로써 효율성과 확장성을 향상시킬 수 있습니다.
• 가능한 한 캐싱 테크닉을 활용하여 데이타베이스 쿼리가 아예 수행되지 않도록 하는 것이 좋습니다.

제공 : DB포탈사이트 DBguide.net

+ Recent posts