일반 PHP 프로젝트 개발 환경에서 docker 사용하기

😢 이 페이지는 다음 주소로 변경될 예정입니다.

지금 있는 회사에서도 정말 오래된 php 페이지가 발굴되어 작업해야 하는 경우가 간혹 있다. 예전에는 그냥 MAMP 같은 패키지를 사용해도 큰 문제가 없었다. 이 회사에서는 기본적으로 포함되어 있지 않은 익스텐션을 사용하는 경우가 많아서 (ldap이라든지) 여기 온 이후로는 docker를 많이 사용하고 있다. 물론 배포 환경은 그대로라서 로컬에서만 주로 사용하고 있다. 배포까지 일괄적으로 사용하지 못하는게 좀 아쉽다.

라라벨과 같은 프레임워크를 사용하고 있으면 이미 공개된 docker도 많고 튜토리얼도 찾기 쉽다. 대신 예전 방식으로 작성된 코드를 기준으로 설명하는건 별로 못본 것 같아서 간단하게 정리하려고 한다. 이 글에서는 용어 없이 슥슥 넘어가는 부분이 많다. 또한 실제로 배포 환경까지 사용하지 않고 로컬에서만 사용하더라도 편리한 점이 많다. 그래서 단순히 로컬에서 php 프로젝트를 돌린다는 것 자체에 한정했다. 이 글에서는 docker-compose로 php-apache, mysql 스택을 빠르게 구성하는 방법 을 살펴본다.

도커에 대해 자세히 알고 싶다면 다음 글을 보자.

이 글에서는 다음 이미지를 사용하고 있다.

여기서 사용한 모든 코드는 haruair/docker-simple-php 리포지터리에서 확인할 수 있다.

docker 설치하기

먼저 docker를 받아 설치한다.

환경을 파일로 작성하기

이제 구축할 환경을 파일로 먼저 만든다. 다음 내용에 따라서 docker-compose.ymlDockerfile을 작성한다.

docker-compose.yml 작성하기

docker-compose.yml은 스마트폰을 예로 들면 어떤 앱을 설치하고 앱을 어떻게 설정할지 정리한 파일이다. 1 docker compose라는 도구가 이 파일을 읽어서 앱을 설치하게 된다.

먼저 다음과 같이 작성한다.

version: '3'

services:

services: 아래로는 설치할 이미지를 작성한다.

#...

services:
  db:
    image: mariadb:5.5
    volumes:
      - "./data:/var/lib/mysql:rw"
    environment:
      - "MYSQL_DATABASE=hello"
      - "MYSQL_USER=hello"
      - "MYSQL_PASSWORD=hello"
      - "MYSQL_ROOT_PASSWORD=root"
    ports:
      - "3306:3306"

먼저 db를 추가했다. image를 보면 mariadb의 공식 이미지를 사용했음을 알 수 있다. 이런 이미지는 docker hub를 통해 공유되는데 앱스토어 정도로 생각하면 되겠다. volumes를 사용해서 앱의 경로에 현재 폴더의 ./data를 연결했다. mysql에 생성되는 데이터베이스는 이 폴더에 저장된다.

대부분의 도커 앱은 environment를 통해서 설정을 할 수 있는데 여기서는 데이터베이스명과 사용자, 비밀번호와 루트 비밀번호를 설정한 것을 볼 수 있다. ports는 현재 컴퓨터의 포트와 해당 앱의 포트를 연결하는 설정이다. 즉, 로컬 호스트의 3306 포트로 접속하면 해당 앱의 3306 포트로 접속하는 것과 동일하게 된다.

#...
services:
  #...
  phpmyadmin:
    image: phpmyadmin/phpmyadmin:4.7
    depends_on:
      - db
    ports:
      - "8000:80"
    environment:
      - "PMA_HOST=db"
      - "PMA_USER=root"
      - "PMA_PASSWORD=root"

다음으로 phpmyadmin을 추가했다. phpmyadmin을 사용하지 않는다면 이 부분은 건너뛴다. mysqlworkbench 등으로 접속해도 사용해서 된다. 나는 웹에서 쓸 수 있는 도구를 선호하는 편이라서 phpmyadmin을 개발 환경에 넣어놓는 편이다.

앞서 db와 크게 차이는 없다. depends_on으로 앞에서 추가한 db에 의존하고 있다고 명시했다. 환경변수에 PWA_HOST를 db라고 설정했는데 docker의 앱은 서로 지정된 이름으로 호스트를 참조할 수 있기 때문이다. 사용자명과 비밀번호는 위에서 설정한 대로 설정해서 접근할 수 있도록 했다.

#...
services:
  #...
  web:
    build:
      context: .
      dockerfile: ./Dockerfile
    volumes:
      - ".:/var/www/html"
    depends_on:
      - db
    ports:
      - "80:80"

마지막으로 web을 추가했다. 앞서 추가했던 이미지와 다르게 build가 포함되어 있다. buildDockerfile을 참고해서 이미지를 생성한다. 대부분 도커 앱은 환경변수로도 제어할 수 있지만 앱 안에 확장 기능을 설치하거나 하는 동작도 가능하도록 Dockerfile을 사용할 수 있다. 자세한 내용은 아래서 다룬다. context는 이미지 생성에서 사용할 기본 경로를 지정하는데 사용하며 다음 내용에서 설명한다. Dockerfile의 명칭이 다르거나 경로가 다르면 dockerfile을 통해 지정할 수 있다. volumes에서 현재 프로젝트의 경로를 앱 내 웹서버의 기본 경로에 연결한다. ports에 보면 앱의 80 포트를 로컬의 80포트와 연결했다. http://localhost/를 입력하면 앱에 있는 서버로 연결되게 된다.

작성을 마친 docker-compose.yml 파일은 다음과 같다. php앱을 구동하기 위해서 필요한 환경을 한 위치에 모두 작성했다.

version: '3'

services:
  db:
    image: mariadb:5.5
    volumes:
      - "./data:/var/lib/mysql:rw"
    environment:
      - "MYSQL_DATABASE=hello"
      - "MYSQL_USER=hello"
      - "MYSQL_PASSWORD=hello"
      - "MYSQL_ROOT_PASSWORD=root"
    ports:
      - "3306:3306"
  phpmyadmin:
    image: phpmyadmin/phpmyadmin:4.7
    depends_on:
      - db
    ports:
      - "8000:80"
    environment:
      - "PMA_HOST=db"
      - "PMA_USER=root"
      - "PMA_PASSWORD=root"
  web:
    build:
      context: .
      dockerfile: ./Dockerfile
    volumes:
      - ".:/var/www/html"
    depends_on:
      - db
    ports:
      - "80:80"

Dockerfile 작성하기

앞서 설정에서 작성했던 내용대로 web에 사용하기로 한 Dockerfile을 작성해야 한다. 여기서는 php의 공식 이미지를 기반으로 사용한다. 파일에 다음 내용을 추가하자.

FROM php:5.6-apache

php:5.6-apache를 기반 이미지로 사용했다. Docker hub에 php:5.6-apache로 지정되어 있는 Dockerfile의 내용을 그대로 상속하게 된다. 이제 필요한 패키지와 확장을 설치한다. Dockerfile로 이미지를 생성하면 매 명령마다 중간 이미지를 생성하기 때문에 필수적인 패키지와 확장을 우선순위로 두면 나중에 비슷한 이미지를 생성할 때 더 빠르게 동작한다.

# ...
RUN apt-get update
RUN apt-get install -y git zip

RUN apt-get install -y libpng12-dev libjpeg-dev
RUN apt-get install -y mysql-client
RUN docker-php-ext-configure gd --with-png-dir=/usr --with-jpeg-dir=/usr \
&& docker-php-ext-install gd

RUN docker-php-ext-install mbstring
RUN docker-php-ext-install mysqli
RUN docker-php-ext-install pdo
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install opcache

RUN apt-get install -y libssl-dev openssl
RUN docker-php-ext-install phar

RUN apt-get clean \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

이제 apache의 모드를 활성화하고 웹서버에 반영하는 부분을 추가한다.

# ...
RUN a2enmod rewrite
RUN a2enmod headers
RUN apache2ctl -k graceful

여기서 작성한 Dockerfile의 전체 내용은 다음과 같다.

FROM php:5.6-apache

RUN apt-get update
RUN apt-get install -y git zip

RUN apt-get install -y libpng12-dev libjpeg-dev
RUN apt-get install -y mysql-client
RUN docker-php-ext-configure gd --with-png-dir=/usr --with-jpeg-dir=/usr \
&& docker-php-ext-install gd

RUN docker-php-ext-install mbstring
RUN docker-php-ext-install mysqli
RUN docker-php-ext-install pdo
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install opcache

RUN apt-get install -y libssl-dev openssl
RUN docker-php-ext-install phar

RUN apt-get clean \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN a2enmod rewrite
RUN a2enmod headers
RUN apache2ctl -k graceful

docker 실행하기 및 종료하기

docker-compose up을 실행한다. 처음 실행하면 이미지를 받고 빌드를 시작하는 것을 확인할 수 있다.

$ docker-compose up
Creating network "dockerhello_default" with the default driver
Pulling db (mariadb:5.5)...
5.5: Pulling from library/mariadb
4269eaa217cc: Downloading [=========>      ]  14.12MB/38.11MB
b5d5817a79f8: Download complete
5a270f0327f3: Download complete
911f94a14d77: Download complete
114588764b3b: Downloading [=============>  ]   5.12MB/5.994MB
d1dcaee5ec4a: Download complete

모든 빌드가 완료되면 현재 폴더에 있는 내용을 http://localhost/를 통해 접속할 수 있다.

앞서 phpmyadmin도 포함했고 포트에 연결했었다. 이제 http://localhost:8000/로 접속하면 phpmyadmin도 잘 실행되고 있는 것을 확인할 수 있다.

docker-compose down으로 종료한다.

docker 컨테이너 다루기

docker-compose로 실행한 후에 실행된 컨테이너를 보기 위해서는 docker ps 명령을 사용할 수 있다.

$ docker ps
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS              PORTS                            NAMES
bacdc4de6660        dockerhello_web             "docker-php-entrypoi…"   6 minutes ago       Up 5 minutes        0.0.0.0:80->80/tcp               dockerhello_web_1
408909957ec4        phpmyadmin/phpmyadmin:4.7   "/run.sh phpmyadmin"     6 minutes ago       Up 5 minutes        9000/tcp, 0.0.0.0:8000->80/tcp   dockerhello_phpmyadmin_1
4949e0a2d10f        mariadb:5.5                 "docker-entrypoint.s…"   6 minutes ago       Up 5 minutes        0.0.0.0:3306->3306/tcp           dockerhello_db_1

그 외에도 다양한 docker 명령어를 직접 사용해서 컨테이너를 제어할 수 있다. 다음 명령으로 컨테이너에 쉘을 실행하고 접속할 수 있다.

$ docker exec -it dockerhello_web_1 bash
[email protected]:/var/www/html# 

다만 이렇게 접속해서 설정을 변경한다면 지금 당장은 문제가 없겠지만 다시 이미지를 생성했을 때는 그 변경 부분이 새 이미지에서는 적용되지 않는다. 도커파일을 사용하면 그런 반복되는 과정을 없에고 파일에 명시적으로 남기는 것으로 매번 동일한 환경을 구성할 수 있다. 설정을 추가하거나 변경할 필요가 있다면 그 명령어는 Dockerfile에 정의하자.


docker-compose를 처음 사용하고 난 후에 docker로 환경을 구성한다는 것이 더 와닿아서 일반적인 설명과는 조금 다른 순서로 적어봤다. 시작부터 대뜸 복잡하게 느껴졌다면 (죄송하지만) 위에 추천한 글을 읽어보자.

여기서는 웹서버가 포함된 php를 사용했지만 fpm을 사용하는 php로 분리하고 별도의 웹서버 컨테이너를 사용하는 것도 가능하다. 반대로 한 이미지에 lamp 스택을 모두 담고 있는 linode/lamp와 같은 경우도 존재한다. 개인적으로는 역할별로 컨테이너를 분리하기를 선호하는데 상황에 맞게 전략을 짜야겠다.


  1. 이런 역할을 오케스트레이션(orchestration)이라고 한다. 

php callable 살펴보기

😢 이 페이지는 다음 주소로 변경될 예정입니다.

php에서는 callable 이라는 타입 힌트를 제공한다. 이 타입 힌트는 말 그대로 호출이 가능한 클래스, 메소드, 또는 함수인 경우에 사용할 수 있다. php에서는 타입이 별도의 타입으로 존재하지 않는 대신에 문자열로 처리하고 있어서 다소 모호한 부분도 있다. callable을 타입 힌트로 사용했을 때 어떤 값을 넘길 수 있는지 명확히 알고 있어야 한다.

function callableOnly(callable $callable): void {
    // callable에 해당하면 다음처럼 호출할 수 있음
    call_user_func($callable);

    // 일부를 제외하고는 다음과 같이 호출 가능함
    $callable();
}

특히 callable은 명확한 제한 없이 열어두고 사용하면 보안 문제 등을 만들어낼 수 있기 때문에 유의해야 한다.

callable

다음은 callable에 해당하는 경우로 상당히 다양한 형태로 callable을 정의할 수 있다. 여기서는 callable인지 확인하는 is_callable() 함수를 사용했다.

함수

function sayHello() {
    echo 'Hello';
}
is_callable('sayHello'); // true

꼭 사용자 정의 함수가 아니더라도 이와 같이 사용할 수 있다. 다만 언어에서 제공하는 구조는 callable에 해당하지 않는다. 예를 들면 isset(), empty(), list()는 callable이 아니다.

is_callable('isset'); // false

익명 함수

$hello = function () {
    echo 'Hello';
};

is_callable($hello); // true

정적 메소드

class HelloWorld()
{
    static function say()
    {
        echo 'Hello World!';
    }
}
is_callable('HelloWorld::say'); // true
is_callable(['HelloWorld', 'say']); // true
is_callable([HelloWorld::class, 'say']); // true

::class 상수는 PHP 5.5.0 이후로 사용할 수 있는데 해당 클래스의 네임스페이스를 포함한 전체 클래스명을 반환한다. 문자열로 사용하는 경우에는 개발도구에서 정적분석을 수행하지 못하기 때문에 오류를 검출하기 어렵다. 대신에 이 상수를 사용하면 현재 맥락에서 해당 클래스가 선언되어 있는지 없는지 검사해주기 때문에 이런 방식으로 많이 작성한다.

주의할 점은 정적분석 기능이 없는 개발도구에서는 ::class를 사용해도 문자열을 사용하는 것과 차이가 없다. ::class는 실제로 해당 클래스로 인스턴스를 생성하거나 하지 않기 때문에 autoload와는 상관 없이 동작하기 때문이다.

echo SomethingNotDefined::class; // "SomethingNotDefined"

대신 런타임에서 is_callable을 사용하거나 callable로 넘겨주는 경우에 정적 메소드의 경우는 autoload를 통해 검사하는 식으로 동작한다.

클래스 인스턴스 메소드

class Person
{
    protected $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function getName(): string
    {
        return $this->name;
    }
}

$edward = new Person('Edward');
is_callable([$edward, 'getName']); // true

클래스의 스코프 해결 연산자를 이용한 메소드

스코프 해결 연산자(Scope Resolution Operator)를 callable에서도 사용할 수 있다. Paamayim Nekudotayim라고 부르는 ::를 의미한다.

class Animal
{
    public function getType()
    {
        echo 'Animal';
    }
}

class Dog extends Animal
{
    public function getType()
    {
        echo 'Dog';
    }
}

$dog = new Dog;
is_callable([$dog, 'parent::getType']); // true
call_user_func([$dog, 'parent::getType']); // Animal

$callable(); // 이 경우에는 이 방식으로 호출할 수 없음

구현 메소드 대신 부모 클래스의 메소드를 직접 호출할 수 있다. 관계를 뒤집는 좋지 않은 구현이므로 이런 방식에 의존적인 코드는 작성하지 않는다.

매직 메소드 __invoke()

__invoke() 매직 메소드가 구현된 클래스는 인스턴스를 일반 함수처럼 호출할 수 있다.

class PersonSay
{
    protected $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function __invoke()
    {
        echo "Hello, {$this->name} said.";
    }
}

$say = new PersonSay('Edward');
is_callable($say); // true
call_user_func($say); // Hello, Edward said.

익명 클래스의 경우도 호출 가능하다.

$say = new class {
    public function __invoke(string $name)
    {
        echo "What up, {$name}?";
    }
};
is_callable($say); // true
call_user_func($say, 'Edward'); // What up, Edward?

이 매직 메소드는 손쉽게 상태를 만들어낼 수 있어서 유용할 때가 종종 있다.


Iterator를 callable로 사용할 수 있을까?

Iterator를 넘기면 인스턴스를 넘긴 것으로 인식해서 __invoke() 구현을 확인한다. 즉, Iterator를 루프를 돌려서 사용하지는 않는다.

Closure vs callable vs 인터페이스

매개변수로 익명함수만을 받고 싶다면 Closure를 지정할 수 있다. 하지만 익명함수에도 use 키워드로 스코프를 집어 넣거나 global로 전역 변수에 접근하는 방식도 여전히 가능하기 때문에 callable이 아니더라도 callable 만큼 열려 있는 것이나 마찬가지라는 점에 유의해야 한다. 열려있는 것 자체는 문제가 아니지만 Closure, callable은 전달받은 함수가 사용하기 적합한지 판단해야 하는 경우가 생긴다. 예를 들면 매개변수의 숫자라든지, 타입이라든지 사용 전에 확인해야 하는 경우가 있다.

그래서 단순히 함수 기능이 필요하더라도 계약을 명확하게 들어내야 한다면 인터페이스를 활용하는게 바람직하다. 인터페이스를 사용하면 전통적인 방식대로 인터페이스를 구현해서 사용하면 되겠다. 물론 익명 클래스로 다음처럼 사용할 수 있다. 익명 함수에 비해서는 다소 장황하게 느껴질 수 있지만 사전조건으로 검증해야 하는 내용을 줄일 수 있다.

interface NamedInterface
{
    public function getName(): string;
}

function sayHello(NamedInterface $named): void {
    echo "Hello! {$named->getName()} said.";
}

sayHello(new class implements NamedInterface {
    public function getName(): string {
        return 'Edward';
    }
});

모든 방법에는 장단점이 있으므로 필요에 따라 어느 접근 방식을 택해야 할지 결정 해야겠다.

신입 PHP 개발자가 읽어야 하는 책

😢 이 페이지는 다음 주소로 변경될 예정입니다.

나도 신입으로 일을 시작했을 때 혼자 인터넷 검색창을 붙들고 코드와 씨름한 경험이 있었다. 작은 회사에서 유일한 개발자라 물어볼 선임도 없었고, 문제는 어떻게든 기간 내에 해결해야 하는 상황이 많았다. 모두가 신입 시절을 거치는 동안 그런 벽을 마주할 때가 있을 것 같다. 넘어야 할 산은 높고 나는 너무나 작게만 느껴지는 그런 경험을 거치고서 각자 지금의 자리에 있지 않나, 생각한다. <바쁜 팀장님 대신 알려주는 신입 PHP 개발자 안내서>라는 책 제목을 보는 순간에 그 신입 당시의 기억이 먼저 떠올랐다.

바쁜 팀장님 대신 알려주는 신입 PHP 개발자 안내서 표지

개발을 코드를 작성해서 실행하는 일이라고 단순하게 설명할 수 있지만, 현실에서는 그렇게 간단하지 않다. 코드를 어떻게 잘 작성하는가도 중요한 주제지만 그만큼 코드를 잘 관리하는 일도 중요하다. 게다가 코드가 실행되는 환경을 이해하는 과정도 필요하다. 환경도 여러 계층에 걸쳐 있다면 두루두루 살펴봐야 한다. 언어도, 환경도 서로 쉽게 이해하고 공유할 수 있도록 다양한 규칙과 규약을 정리해놓고 있고 이런 부분도 숙지해야 한다. 개발이라고 말하기에는 다각적으로 알아야 하는 부분이 많다.

신입으로 첫날 출근하면 이런 지식에 압도당한다. 신입으로 시작할 때 가장 막막한 점은 단순히 언어에 대한 이해를 넘어서 이런 다양한 지식을 한꺼번에 흡수해야 한다는 점이다. 순식간에 넓은 분야를 깊이 있게 이해하는 일은 생각만으로도 벅차다. 알아야 할 모든 내용을 단번에 이해하면 좋겠지만 절대 쉬운 일이 아니다. 그런 막막한 상황에서 가장 중요한 것은 알아야 할 내용의 키워드를 습득하는 것이다. 키워드를 알면 그 키워드를 중심으로 아는 범위를 쉽게 늘릴 수 있다. 그런 점에서 이 책은 폭 넓으면서도 중요한 키워드를 모두 포함하고 있어서 학습에 좋은 가이드가 된다.

일단 PHP를 기준으로 설명하기 때문에 PHP를 사용하는 사람이라면 꼭 알아야 할 내용을 잘 다루고 있다. 그리고 많은 내용을 PHP에 할애하고 있긴 하지만 거기에 덧붙여 지금 시대에 웹개발을 한다면 필수적으로 알아야 할 다양한 키워드를 장마다 풀고 있다. 또한, 각 문제와 주제에 대해 어떤 식으로 접근해야 하는지 복잡하지 않게 설명한다. 실제로 문제를 마주하게 될 때 어떤 식으로 생각하고 찾아봐야 하는지 그 방법도 잘 전달하고 있다.

  • 저장소가 뭔가요? (버전 관리 시스템)
  • 저장소의 소스코드를 받았는데 왜 안되죠? (컴포저)
  • 제 컴퓨터에서는 잘 되는데요? (가상 머신을 이용한 개발 환경 구축)
  • 어떤 파일을 고쳐야 할 지 모르겠어요 (프런트 컨트롤러 패턴과 MVC 패턴)
  • GET, POST는 알겠는데 PUT, DELETE는 뭔가요? (HTTP와 REST)
  • 그렇게까지 해야 하나요? (시큐어 코딩)
  • 그냥 제 스타일대로 하면 안되나요? (코딩 컨벤션과 PHP 표준 권고)
  • MySQLi는 나쁜건가요? (PDO와 ORM)
  • 메모장에 코딩하면 안되나요? (통합 개발 환경)

책을 읽기 시작하자마자 끝까지 고개를 끄덕이며 읽었다. 내가 신입으로 들어갔을 때 이런 책이 있었으면 얼마나 수월하게 배우기 시작했을까. 주변에 PHP를 사용하는 사람이 있다면 꼭 알려주자. 신입 PHP 개발자에게는 어떻게 시작해야 하는지 좋은 가이드가 되고 선임이나 팀장급 이상이라면 어떤 내용을 신입에게 가르쳐야 하는지 명확한 지침이 되는 책이다.