주요글: 도커 시작하기

이전 글에서 도커 이미지에 대해 알아봤는데 이어서 Dockerfile을 도커 이미지 파일을 생성하는 방법을 살펴보자.

초 간단 Dockerfile

다음은 매우 간단한 Dockerfile 예이다.

FROM alpine:3.10

ENTRYPOINT ["echo", "hello"]

FROM은 새로운 이미지를 생성할 때 기반으로 사용할 이미지를 지정한다. 위 코드는 alpine:3.10 이미지를 기반 이미지로 사용한다.

ENTRYPOINT는 컨테이너를 시작할 때 실행할 명령어를 입력한다. 위 코드는 'echo hello'를 실행 명령어로 사용한다. ENTRYPOINT는 두 방식으로 입력할 수 있는데 이에 대한 내용은 뒤에서 다시 설명한다.

Dockerfile을 작성했으면 docker build 명령어로 이미지를 생성할 수 있다.

docker build --tag echoalpine:1.0 .

--tag(또는 -t) 옵션은 새로 생성할 이미지 이름을 지정한다. 여기서는 리포지토리 이름으로 echoalpine을 사용하고 태그로 1.0을 사용했다. 마지막에 점(.)은 Dockerfile의 위치를 경로를 지정한다. 파일 이름이 Dockerfile이 아닌 경우 --file(또는 -f) 옵션을 사용해서 파일 이름을 지정한다.

Dockerfile이 위치한 디렉토리에서 위 명령어를 실행하면 다음과 같이 새로운 도커 이미지를 생성한다. docker images 명령어로 새로 생긴 이미지를 확인할 수 있다.

vagrant@ubuntu-bionic:~/sample$ docker build --tag echoalpine:1.0 .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM alpine:3.10
 ---> 961769676411
Step 2/2 : ENTRYPOINT ["echo", "hello"]
 ---> Running in 943587678aaf
Removing intermediate container 943587678aaf
 ---> 109b1eea8279
Successfully built 109b1eea8279
Successfully tagged echoalpine:1.0

새로 생성한 이미지를 사용해서 컨테이너를 생성하고 실행해보자. hello가 콘솔에 출력되고 컨테이너가 종료되는 것을 알 수 있다.

$ docker run --rm echoalpine:1.0
hello

컨테이너는 ENTRYPOINT로 지정한 echo hello 명령어를 실행한다. echo는 콘솔에 문자열을 출력하고 종료되므로 컨테이너도 함께 종료된다.

간단한 예제 구성

앞의 Dockerfile은 너무 간단했다. 조금 더 현실적인 Dockerfile을 만들어보자. 이를 위해 사용할 파일은 다음고 같다.

  • hello.jar : 스프링 부트로 만든 간단한 웹 어플리케이션 (다운로드)
  • entrypoint.sh : hello.jar를 실행하기 위한 간단한 쉘 파일

entrypoint.sh 파일은 다음과 같다.

#!/bin/sh

ACTIVE_PROFILE="${PROFILE:-dev}"

echo "ACTIVE_PROFILE=${ACTIVE_PROFILE}"

exec java -Djava.security.egd=file:/dev/./urandom \
          -Dspring.profiles.active=${ACTIVE_PROFILE} \
          -jar hello.jar

이 코드는 spring.profiles.active의 값으로 PROFILE 환경 변수를 사용한다. 이 환경 변수가 존재하면 그 값을 사용하고 존재하지 않으면 dev를 사용한다. hello.jar는 웹 어플리케이션으로 선택한 프로필에 따라 제공하는 URL이 달라진다.

  • prod 프로필 : /actuator/env, /actuator/metric, /actuator/info, /actuator/health 제공
  • 그 외 프로필 : /actuator/beans 등 거의 모든 /actuator 엔드포인트 제공

주요 명령: FROM, RUN, ENV, COPY, ENTRYPOINT

hello.jar와 entrypoint.sh 파일로 도커 이미지를 만들기 위해 사용할 Dockerfile은 다음과 같다.

FROM openjdk:8-jdk-alpine

RUN apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime

WORKDIR /app
COPY hello.jar hello.jar
COPY entrypoint.sh run.sh
RUN chmod 774 run.sh

ENV PROFILE=local

ENTRYPOINT ["./run.sh"]

FROM, ENTRYPOINT 외에 몇 가지 명령어를 추가로 사용했다. 각 명령어는 다음과 같다.

  • FROM : 이미지를 생성할 때 사용할 기반 이미지를 지정한다. 예제에서는 openjdk:8-jdk-alpine 이미지를 사용했다. 이 이미지는 알파인 OS에 JDK 8을 설치한 이미지이다.
  • RUN : 이미지를 생성할 때 실행할 코드를 지정한다. 예제에서는 패키지를 설치하고 파일 권한을 변경하기 위해 RUN을 사용했다.
  • WORKDIR : 작업 디렉토리를 지정한다. 해당 디렉토리가 없으면 새로 생성한다. 작업 디렉토리를 지정하면 그 이후 명령어는 해당 디렉토리를 기준으로 동작한다.
  • COPY : 파일이나 폴더를 이미지에 복사한다. 위 코드에서 두 번째 COPY 메서드는 entrypoint.sh 파일을 이미지에 run.sh 이름으로 복사한다. 상대 경로를 사용할 경우 WORKDIR로 지정한 디렉토리를 기준으로 복사한다.
  • ENV : 이미지에서 사용할 환경 변수 값을 지정한다. 위 코드는 PROFILE 환경 변수의 값으로 local을 지정했는데 이 경우 컨테이너를 생성할 때 PROFILE 환경 변수를 따로 지정하지 않으면 local을 기본 값으로 사용한다.
  • ENTRYPOINT : 컨테이너를 구동할 때 실행할 명령어를 지정한다. 위에서는 run.sh을 실행하도록 설정했다.

위 Dockerfile을 이용해서 생성한 이미지는 /app 디렉토리에 hello.jar 파일과 run.sh 파일을 포함하며 컨테이너를 시작할 때 run.sh 파일을 실행한다.

먼저 docker build 명령어를 이용해서 이미지를 만들어보자.

$ docker build --tag hello:1.0 .
Sending build context to Docker daemon  18.39MB
Step 1/8 : FROM openjdk:8-jdk-alpine
 ---> a3562aa0b991
Step 2/8 : RUN apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
 ---> Running in a6973d93f849
fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/community/x86_64/APKINDEX.tar.gz
(1/1) Installing tzdata (2019b-r0)
Executing busybox-1.29.3-r10.trigger
OK: 107 MiB in 55 packages
Removing intermediate container a6973d93f849
 ---> d5c2dda85cbc
Step 3/8 : WORKDIR /app
 ---> Running in c1b187ed05b6
Removing intermediate container c1b187ed05b6
 ---> 85f74df29ec8
Step 4/8 : COPY hello.jar hello.jar
 ---> be5100270e6c
Step 5/8 : COPY entrypoint.sh run.sh
 ---> cca2ad0f28fb
Step 6/8 : RUN chmod 774 run.sh
 ---> Running in dd9b59be1c12
Removing intermediate container dd9b59be1c12
 ---> 854ba56e2a00
Step 7/8 : ENV PROFILE=local
 ---> Running in eae1bb4e9b5c
Removing intermediate container eae1bb4e9b5c
 ---> f4ba7e11a824
Step 8/8 : ENTRYPOINT ["./run.sh"]
 ---> Running in 449c98a2bdf9
Removing intermediate container 449c98a2bdf9
 ---> 023caa2dd38d
Successfully built 023caa2dd38d
Successfully tagged hello:1.0

이미지를 성공적으로 만들었으면 docker run 명령어로 이미지를 이용해서 컨테이너를 시작하자.

$ docker run --rm -p 9090:9090 hello:1.0
ACTIVE_PROFILE=local

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.9.RELEASE)

...생략

docker run 명령어를 실행하면 ACTIVE_PROFILE=local 문자열이 출력되는데 이 문자열은 run.sh이 출력한 것이다. run.sh 파일은 PROFILE 환경 변수의 값을 ACTIVE_PROFILE에 할당하므로 출력 결과를 통해 PROFILE 환경 변수의 값으로 local을 사용한 것을 알 수 있다(run.sh 파일은 PROFILE 환경 변수가 없으면 dev를 기본 값으로 사용하도록 구현했다). 이 값은 Dockerfile에서 ENV 명령어를 사용해서 PROFILE 환경 변수에 지정한 값을 사용한 것이다.

PROFILE 환경 변수의 값을 바꾸고 싶다면 컨테이너를 시작할 때 -e 옵션을 사용해서 다른 값을 지정하면 된다.

$ docker run --rm -p 9090:9090 -e PROFILE=prod hello:1.0
ACTIVE_PROFILE=prod

ENTRYPOINT의 두 형식

ENTRYPOINT는 exec 형식과 shell 형식의 두 가지 형식으로 지정할 수 있다. 이 중 exec 형식을 추천한다. 예에서 사용한 앞서 예에서도 ["echo", "hello"]나 ["./run.sh"]는 모두 exec 형식이다.

exec는 다음 형식을 갖는다.

  • ENTRYPOINT ["실행명령어", "인자1", "인자2", ... ]

배열에서 첫 번째는 실행할 명령어이다. 두 번째부터는 명령어의 인자로 전달된다.

shell 형식은 실행 명령어를 문자열로 입력한다.

ENTRYPOINT echo hello

exec 형식을 사용할 때와의 차이점은 shell 형식을 사용하면 /bin/sh -c로 명령을 실행한다는 것이다. 즉 위와 같은 ENTRYPOINT를 사용하면 실제 실행하는 명령어는 다음과 같다.

/bin/sh -c 'echo hello'

여러 개 ENTRYPOINT를 지정해도 마지막 한 개만 적용된다.

COPY와 ADD 명령어

COPY는 파일을 이미지에 복사한다. 형식은 다음과 같다.

  • COPY src1 src2 ... dest
  • COPY ["src1", "src2", ..., "dest"]

dest가 상대 경로면 WORKDIR 명령어로 지정한 경로에 복사한다. 예를 들어 다음은 entry.sh 파일을 이미지의 /app/run.sh 파일로 복사한다.

WORKDIR /app
COPY entry.sh run.sh

다음은 dest로 절대 경로를 사용했다. 이 경우 entry1.sh 파일은 /usr/bin/ 디렉토리에 복사하고 entry2.sh 파일은 /usr/bin/ 디렉토리에 run.sh로 복사한다.

WORKDIR /app
COPY entry1.sh /usr/bin/
COPY entry2.sh /usr/bin/run.sh

src는 "*"이나 "?"와 같은 와일드카드를 포함한 GO 언어의 매치 패턴을 이용할 수 있다.

ADD 명령어는 COPY 명령어와 비슷하게 이미지에 복사한다. COPY 명령어와의 차이라면 복사한 파일의 압축을 푼다는 것이다. 아래 명령어는 pinpoint-agent-1.8.4.tar.gz 파일을 /pinpoint 디렉토리에 풀어서 복사한다.

ADD pinpoint-agent-1.8.4.tar.gz /pinpoint

빌드 컨텍스트 주의 사항

도커 이미지를 빌드할 때 출력되는 메시지를 보자.

$ docker build --tag hello:1.0 .
Sending build context to Docker daemon  18.39MB
...생략

빌드 컨텍스트를 전송한다는 메시지가 나온다. 빌드 컨텍스트는 빌드를 실행할 때 사용할 파일 집합이다. docker build 명령어는 도커 데몬에 빌드 컨텍스트를 전송한다. 빌드 컨텍스트는 빌드 명령을 실행하는 디렉토리와 그 하위 디렉토리에 포함된 전체 파일이다.

빌드 컨텍스트에는 COPY나 ADD에서 사용하지 않는 파일도 포함되므로 빌드 컨텍스트에는 필요한 파일만 포함해야 한다. 부득이하게 빌드 과정에서 사용하지 않는 파일이 존재할 경우 .dockerignore 파일을 만들어 컨텍스트에서 제외할 대상을 지정하면 된다.

도커 허브를 이용한 이미지 공유

이미지를 생성하면 도커 리포지토리를 이용해서 공유할 수 있다. 다양한 도커 리포지토리가 존재하는데 이 중 대표적인 것이 도커 허브이다. 지금까지 예에서 사용한 nginx나 alpine과 같은 이미지가 위치한 곳이기도 하다. https://hub.docker.com 사이트에 가입하면 도커 허브에 이미지를 푸시해서 공유할 수 있다.

푸시 방법은 어렵지 않다. 먼저 생성한 이미지에 추가로 태그를 붙인다. 추가 태그에는 리포지토리 계정 이름을 사용한다. 예를 들어 도커 허브 계정이 madvirus라면 madvirus/리포지토리이름:태그 형태를 사용한다. 다음은 simplenode:0.1 이미지에 madvirus/simplenode:0.1 태그를 추가하는 예인데 이때 madvirus라 도커 허브 계정이다.

$ docker tag simplenode:0.1 madvirus/simplenode:0.1

 

docker login 명령어를 사용해서 도커 허브에 로그인한다.

$ docker login
Login with your Docker ID to push and pull images from Docker Hub. ...생략
Username: madvirus
Password:
...생략
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded

로그인에 성공했다면 docker push로 이미지를 푸시한다.

$ docker push madvirus/simplenode:0.1
The push refers to repository [docker.io/madvirus/simplenode]
8eac20925476: Pushed
a3f62211f277: Pushed
e7ae04d3f37c: Mounted from library/node
e29ab5067804: Mounted from library/node
ae4ceb8dc557: Mounted from library/node
f1b5933fe4b5: Mounted from library/node
0.1: digest: sha256:d0299960fb25aae581200d143291e3fd48a18dfbdd53333df8557755b00bef14 size: 1572

푸시가 끝나면 도커 허브에서 이미지를 가져와 사용할 수 있다.

 docker run -d --rm -p 5000:5000 madvirus/simplenode:0.1

관련 자료

관련 글

  1. 2019.12.19 18:03

    비밀댓글입니다

  2. road-young 2020.02.08 23:17

    우선 좋은글 감사합니다.
    그런데 올려주신 hello.jar 간단한 controller 매핑 정보 없을까요?

    • 최범균 madvirus 2020.02.09 14:20 신고

      /actuator/env, /actuator/metric, /actuator/info, /actuator/health, /actuator/beans 등 /actuator 엔드포인트를 URL로 제공합니다. 그리고 /now도 제공합니다.

도커를 사용하면 mysql이나 nginx처럼 이미 제공하는 이미지를 사용해서 소프트웨어를 쉽게 실행할 수 있지만 단지 이것만이 도커를 사용하는 아니다. 도커를 사용하는 또 다른 이유는 직접 개발한 소프트웨어를 도커 이미지로 만들어 배포하고 실행하기 위함이다. 즉 도커를 잘 활용하려면 이미지에 대한 이해가 필요하다.

이미지 이름

도커 이미지를 이용해서 컨테이너를 생성할 때 이미지 이름을 사용한다.

docker run -it --rm alpine:3.10 sh

위 코드는 alpine:3.10을 이미지 이름으로 사용했다. alpine은 리포지토리 이름이고 3.10은 태그이다. docker images 명령어를 실행하면 로컬에 존재하는 이미지 목록을 표시한다. 이미지 목록을 보면 REPOSITORY 칼럼과 TAG 칼럼을 통해서 이미지의 리포지토리와 태그 값을 확인할 수 있다.

vagrant@ubuntu-bionic:~$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
adminer             latest              09a06e7c3196        2 days ago          87.6MB
nginx               1.17.4              f949e7d76d63        4 days ago          126MB
nginx               latest              f949e7d76d63        4 days ago          126MB
mysql               5.7                 383867b75fd2        2 weeks ago         373MB
mysql               latest              b8fd9553f1f0        2 weeks ago         445MB
centos              7                   67fa590cfc1c        5 weeks ago         202MB
alpine              3.10                961769676411        5 weeks ago         5.58MB
openjdk             8u212-jdk-alpine    a3562aa0b991        4 months ago        105MB
hello-world         latest              fce289e99eb9        9 months ago        1.84kB

위 결과에서 mysql 리포지토리는 태그가 5.7인 이미지와 태그가 latest인 이미지가 존재한다. 여기에 표시된 리포지토리 명은 완전한 리포지토리 명을 짧게 표시한 것이다. 실제 리포토리명은 다음의 세 가지 요소를 가진다.

  • 리포지토리호스트/조직(계정)이름/짧은이름

alpine이나 nginx와 같이 도커가 제공하는 공식 이미지는 짧은 이름으로 이미지를 사용할 수 있다. 실제 공식 이미지의 리포지토리 이름은 다음 형식을 갖는다.

  • docker.io/library/nginx

도커 허브에 개인 계정을 만들어 리포지토리를 만들 수도 있다. 예를 들어 필자는 도커 허브에 madvirus라는 이름으로 가입했는데 이 경우 리포지토리 이름은 madvirus/openjdk-pinpoint와 같은 형태를 갖는다.

도커 허브가 아닌 사설 도커 리포지토리를 사용할 경우 호스트명을 포함한 완전한 리포지토리 이름을 사용해야 한다.

보통 태그 값으로는 버전을 사용한다. 한 이미지에 여러 태그를 붙일 수 있는데 보통 마지막 버전에 해당하는 이미지에는 latest 태그를 함께 붙인다. 이 글을 쓰는 시점에서 nginx:latest 이미지는 nginx:1.17.4 이미지와 같은 이미지이다. docker images 결과를 보면 nginx의 latest 태그와 1.17.4 태그의 이미지ID가 같은 것을 알 수 있다.

참고로 컨테이너를 생성할 때 태그를 지정하지 않으면 latest를 기본 값으로 사용한다.

이미지 레이어

기존 이미지를 이용해서 새로운 이미지를 만들어보자.

vagrant@ubuntu-bionic:~$ docker run --name alpine_custom alpine:3.10 touch /mycustom.txt

vagrant@ubuntu-bionic:~$ docker commit alpine_custom myimage
sha256:422d23665db418c26a463a3aeb4d92b43a9c51c056aae34dea26a3a4067c1f9a

위 코드는 alpine:3.10 이미지를 이용해서 alpine_custom 컨테이너를 생성한다. 컨테이너를 실행할 때 touch 명령어를 사용해서 루트에 mycustom.txt 파일을 생성한다.

docker commit 명령어는 컨테이너를 이용해서 새로운 이미지를 생성한다. 위 코드는 alpine_custom 컨테이너를 이용해서 myimage라는 이미지를 생성한다. 태그를 지정하지 않았으므로 latest를 태그로 사용한다.

이제 생성한 alpine_custom 컨테이너를 삭제하고 새로 생성한 myimage 이미지를 이용해서 새로운 컨테이너를 사용해보자.

vagrant@ubuntu-bionic:~$ docker rm -v alpine_custom
alpine_custom
vagrant@ubuntu-bionic:~$ docker run --rm myimage ls -la /
total 64
drwxr-xr-x    1 root     root          4096 Sep 29 11:28 .
drwxr-xr-x    1 root     root          4096 Sep 29 11:28 ..
-rwxr-xr-x    1 root     root             0 Sep 29 11:28 .dockerenv
...생략
-rw-r--r--    1 root     root             0 Sep 29 11:15 mycustom.txt
...생략
drwxr-xr-x   11 root     root          4096 Aug 20 10:30 var

myimage 이미지로 생성한 컨테이너에서 ls 명령어를 실행하면 mycustom.txt 파일이 표시된 것을 알 수 있다.

myimage를 조금 더 살펴보자. docker image history 명령어를 실행하면 이미지의 내역을 볼 수 있다. 다음은 실행 결과이다.

vagrant@ubuntu-bionic:~$ docker image history myimage:latest
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
422d23665db4        7 minutes ago       touch /mycustom.txt                             0B
961769676411        5 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           5 weeks ago         /bin/sh -c #(nop) ADD file:fe64057fbb83dccb9…   5.58MB

위 결과에서 IMAGE 칼럼에 표시된 값과 docker images 명령어의 IMAGE ID 칼럼 값을 비교해보자. 결과를 보면 docker image history에서 출력한 9617961769676411가 alpine:3.10 IMAGE ID와 같다.

vagrant@ubuntu-bionic:~$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
myimage             latest              422d23665db4        12 minutes ago      5.58MB
...생략
alpine              3.10                961769676411        5 weeks ago         5.58MB
...생략

 

이번에는 alpine:3.10 이미지의 내역을 보자. myimage 이미지의 내역에서 9617961769676411 부분부터 <missing>의 내용이 완전 동일하다.

vagrant@ubuntu-bionic:~$ docker image history alpine:3.10
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
961769676411        5 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           5 weeks ago         /bin/sh -c #(nop) ADD file:fe64057fbb83dccb9…   5.58MB

도커 이미지 내역은 도커 이미지를 생성할 때 사용한 변경 내역을 보여준다. 도커는 이미지를 생성할 때 전체 파일을 새로 만들지 않고 변경한 파일만 이용해서 새로운 레이어를 생성한다. 예를 들어 myimage:latest 이미지는 alpine:3.10 이미지에서 변경한 내용만 새로운 레이어로 생성한다. 비슷하게 alpine:3.10 이미지는 <missing>으로 표시된 이미지에서 변경한 내용만 레이어로 생성한다. 즉 myimage:latest 이미지는 alpine 이미지로 생성한 컨테이너에서 변경한 파일인 mycustom.txt 파일만 포함한다.

이런 특징을 잘 활용하면 다운로드 받을 이미지의 크기를 줄일 수 있다. 예를 들어 10개의 자바 어플리케이션을 이미지로 만들 때 openjdk:8u212-jdk-alpine 이미지를 하위 레이어로 사용하면 openjdk:8u212-jdk-alpine 이미지와 관련된 파일은 한 번만 다운로드 하고 10개 자바 어플리케이션의 변경 부분만 다운로드하므로 어플리케이션을 구동하기 위해 다운로드해야 하는 이미지의 크기가 줄어든다.

docker commit으로 생성한 이미지 파일은 도커 허브나 별도로 구성한 도커 레지스트리를 이용해서 다른 사람과 공유할 수 있다. 그런데 docker commit을 이용해서 도커 이미지를 만드는 과정은 수작업으로 이루어지므로 불편하고 실수하기 좋다. Dockerfile을 사용하면 이미지 생성 과정을 쉽게 자동화할 수 있는데 다음 글에서 Dockerfile을 이용한 이미지 생성 방법을 살펴보자.

관련 글

특정 URL 이미지를 목록에서 보여주어야 할 때, 동일 URL 이미지를 매번 다운로드 받아 출력하면 비효율적일 뿐만 아니라 사용자에게 보여지는 응답도 느려지게 된다. 사용자에게 응답을 빠르게 보여주기 위해서는 URL 이미지를 메모리나 로컬 파일에 캐싱하도록 구현해야 한다. 이미지를 캐싱함으로써, 동일 URL 이미지를 보여주어야 할 때 다운로드 없이 빠르게 이미지를 사용자에게 보여줄 수 있게 된다.


본 글에서는 이미지를 위한 캐시를 만들어보도록 하겠다. 실제 URL로부터 이미지를 읽어와 캐시에 담고 ImageView에 다운로드 받은 이미지를 보여주는 코드는 '안드로이드에서 URL 이미지를 ImageView에 보여주기' 글을 참고하기 바란다.


이미지 캐시 기능


제공할 기능은 다음과 같다.

  • 이미지 메모리 캐시를 제공한다.
    • 메모리에 지정 개수 만큼의 이미지를 보관한다.
  • 이미지 메모리/파일의 2레벨 캐시를 제공한다. 
    • 캐시에 이미지를 보관하면 메모리와 파일에 동시에 보관된다.
    • 메모리 캐시는 보관할 수 있는 개수에 제한이 있다.
    • 파일 캐시는 보관할 수 있는 전체 크기에 제한이 있다. ('안드로이드에서 파일 캐시 구현하기' 글에서 만든 파일 캐시를 사용해서 구현한다.)
    • 메모리 캐시에 없으면, 파일 캐시로부터 이미지를 읽어온다.

2레벨 캐시를 사용할 경우 자주 사용되는 이미지는 메모리에 담고 일정 크기만큼의 이미지는 파일로도 보관한다. 이를 통해 메모리 용량의 사용을 일정 수준으로 유지하면서 동시에 네트워크 사용을 최소화해서 사용자에게 이미지를 빠르게 보여줄 수 있다.


이미지 캐시  클래스 구성


구현할 이미지 캐시의 클래스 구성은 아래와 같다.



구성요소

설명 

ImageCacheFactory

ImageCache의 생성 및 검색 기능을 제공한다. 

ImageCache

이미지 캐시를 위한 인터페이스를 제공한다. 

MemoryImageCache

메모리 기반의 이미지 캐시를 구현한다. 

FileImageCache 

파일 기반의 이미지 캐시를 구현한다.

ChainedImageCache 

캐시 체인 기능을 제공한다.


ImageCache 인터페이스


public interface ImageCache {


public void addBitmap(String key, Bitmap bitmap);


public void addBitmap(String key, File bitmapFile);


public Bitmap getBitmap(String key);


public void clear();


}


MemoryImageCache 클래스


MemoryImageCache는 내부적으로 LruCache를 사용해서 구현하였다.


package com.toonburi.app.infra.imagecache;


import java.io.File;


import android.graphics.Bitmap;

import android.graphics.BitmapFactory;

import android.support.v4.util.LruCache;


public class MemoryImageCache implements ImageCache {


private LruCache<String, Bitmap> lruCache;


public MemoryImageCache(int maxCount) {

lruCache = new LruCache<String, Bitmap>(maxCount);

}


@Override

public void addBitmap(String key, Bitmap bitmap) {

if (bitmap == null)

return;

lruCache.put(key, bitmap);

}


@Override

public void addBitmap(String key, File bitmapFile) {

if (bitmapFile == null)

return;

if (!bitmapFile.exists())

return;


Bitmap bitmap = BitmapFactory.decodeFile(bitmapFile.getAbsolutePath());

lruCache.put(key, bitmap);

}


@Override

public Bitmap getBitmap(String key) {

return lruCache.get(key);

}


@Override

public void clear() {

lruCache.evictAll();

}


}


FileImageCache 클래스


FileImageCache는 앞서 '안드로이드에서 파일 캐시 구현하기'에서 만든 파일 캐시를 이용해서 구현하였다. 코드는 다음과 같다.


public class FileImageCache implements ImageCache {

private static final String TAG = "FileImageCache";


private FileCache fileCache;


public FileImageCache(String cacheName) {

fileCache = FileCacheFactory.getInstance().get(cacheName);

}


@Override

public void addBitmap(String key, final Bitmap bitmap) {

try {

fileCache.put(key, new ByteProvider() {

@Override

public void writeTo(OutputStream os) {

bitmap.compress(CompressFormat.PNG, 100, os);

}

});

} catch (IOException e) {

Log.e(TAG, "fail to bitmap to fileCache", e);

}

}


@Override

public void addBitmap(String key, File bitmapFile) {

try {

fileCache.put(key, bitmapFile, true);

} catch (IOException e) {

Log.e(TAG, String.format("fail to bitmap file[%s] to fileCache",

bitmapFile.getAbsolutePath()), e);

}

}


@Override

public Bitmap getBitmap(String key) {

FileEntry cachedFile = fileCache.get(key);

if (cachedFile == null) {

return null;

}

return BitmapFactory.decodeFile(cachedFile.getFile().getAbsolutePath());

}


@Override

public void clear() {

fileCache.clear();

}


}


위 코드에서 유의할 점은 FileImageCache 객체를 생성할 때, 파라미터로 전달받은 cacheName을 이용해서 FileCache를 구한다는 점이다. 즉, FileImageCache의 이름과 동일한 이름을 갖는 FileCache가 존재해야 정상적으로 동작한다. 따라서, FileImageCache를 사용하기 전에 다음과 같이 이미지 캐시와 동일한 이름을 갖는 FileCache를 생성해 주어야 한다.


// onCreate 등에서 파일을 이용하는 이미지 캐시 생성 전에 초기화

FileCacheFactory.getInstance().create(cacheName, cacheSize);


ChainedImageCache 클래스


이미지 캐시와 파일 캐시를 1차/2차 캐시로 사용하기 위해 ChainedImageCache 클래스를 만들었다.


public class ChainedImageCache implements ImageCache {


private List<ImageCache> chain;


public ChainedImageCache(List<ImageCache> chain) {

this.chain = chain;

}


@Override

public void addBitmap(String key, Bitmap bitmap) {

for (ImageCache cache : chain) {

cache.addBitmap(key, bitmap);

}

}


@Override

public void addBitmap(String key, File bitmapFile) {

for (ImageCache cache : chain) {

cache.addBitmap(key, bitmapFile);

}

}


@Override

public final Bitmap getBitmap(String key) {

Bitmap bitmap = null;

List<ImageCache> previousCaches = new ArrayList<ImageCache>();

for (ImageCache cache : chain) {

bitmap = cache.getBitmap(key);

if (bitmap != null) {

break;

}

previousCaches.add(cache);

}

if (bitmap == null)

return null;


if (!previousCaches.isEmpty()) {

for (ImageCache cache : previousCaches) {

cache.addBitmap(key, bitmap);

}

}

return bitmap;

}


@Override

public final void clear() {

for (ImageCache cache : chain) {

cache.clear();

}

}


}


ChainedImageCache는 chain에 등록되어 있는 모든 ImageCache를 차례대로 실행한다. getBitmap()은 약간 복잡하다. getBitmap()은 체인을 따라 Bitmap이 존재할 때까지 탐색한다. Bitmap이 발견되면 해당 캐시 이전에 위치한 캐시들(previousCaches에 보관됨)에 Bitmap 정보를 추가해서, 이후 동일 키로 요청이 오면 체인의 앞에서 발견되도록 한다.


ImageCacheFactory 클래스


ImageCacheFactory는 캐시 생성 기능을 제공한다.


public class ImageCacheFactory {


private static ImageCacheFactory instance = new ImageCacheFactory();


public static ImageCacheFactory getInstance() {

return instance;

}


private HashMap<String, ImageCache> cacheMap = new HashMap<String, ImageCache>();


private ImageCacheFactory() {

}


public ImageCache createMemoryCache(String cacheName, int imageMaxCounts) {

synchronized (cacheMap) {

checkAleadyExists(cacheName);

ImageCache cache = new MemoryImageCache(imageMaxCounts);

cacheMap.put(cacheName, cache);

return cache;

}

}


private void checkAleadyExists(String cacheName) {

ImageCache cache = cacheMap.get(cacheName);

if (cache != null) {

throw new ImageCacheAleadyExistException(String.format(

"ImageCache[%s] aleady exists", cacheName));

}

}


public ImageCache createTwoLevelCache(String cacheName, int imageMaxCounts) {

synchronized (cacheMap) {

checkAleadyExists(cacheName);

List<ImageCache> chain = new ArrayList<ImageCache>();

chain.add(new MemoryImageCache(imageMaxCounts));

chain.add(new FileImageCache(cacheName));

ChainedImageCache cache = new ChainedImageCache(chain);

cacheMap.put(cacheName, cache);

return cache;

}

}


public ImageCache get(String cacheName) {

ImageCache cache = cacheMap.get(cacheName);

if (cache == null) {

throw new ImageCacheNotFoundException(

String.format("ImageCache[%s] not founds"));

}

return cache;

}

}


createMemoryCache() 메서드는 메모리만 사용하는 ImageCache를 생성한다. createTwoLevelCache() 메서드는 1차 메모리/2차 파일 기반의 2레벨 캐시를 생성한다.


이미지 캐시 사용하기


다음은 이미지 캐시의 사용 예시이다.


-- onCreate 등 초기화 부분


// 2레벨 캐시(이미지 파일 캐시)를 사용하려면 동일 이름의 파일 캐시를 생성해 주어야 한다.

FileCacheFactory.getInstance().create(cacheName, cacheSize);


// 이미지 캐시 초기화

ImageCacheFactory.getInstance().createTwoLevelCache(cacheName, memoryImageMaxCounts);



-- 이미지 캐시 사용 부분

ImageCache imageCache = ImageCacheFactory.getInstance().getCache(cacheName);

Bitmap bitmap = imageCache.getBitmap(key);

if (bitmap != null) {

imageView.set.....

}


-- 이미지 캐시 추가 부분

imageCache.putBitmap(key, someBitmap);


실제 ImageCache를 사용하는 예제 코드는 '안드로이드에서 URL 이미지를 ImageView에 보여주기'에 있으니 이 글을 참고하면 된다.


관련자료



  1. bluepoet 2013.01.31 17:35

    저도 이번 스타앱 리뉴얼때, 프로게이머 프로필 이미지쪽을 구현할 예정이었는데

    이번 포스팅이 많이 참고가 되겠네요.

    특히나, 체인을 이용한 캐시 구현방법은 참 신선하네요.

    근데, previousCaches에 정보를 넣고 활용하는 쪽은 좀 어렵네요.

    동일 키로 요청이 오면 어떻게 체인의 앞에서 발견되도록 하는 건지 궁금합니다.

  2. andu 2013.05.16 13:28

    FileImageCache 클래스에서 fileCache.clear()가 사용되는데,

    FileCache 클래스에는 clear()가 없네요. 새로 추가된 메소드인가요?

    • 최범균 madvirus 2013.05.16 13:40 신고

      아,, 이게 파일캐시를 먼저 구현하고 그 다음에 이미지 캐시를 구현하는 과정에서 FileCache에 clear() 메서드가 추가되어서 그럽니다. clear() 메서드 및 관련 코드를 이전 글에 반영했으니 참고하세요.

    • andu 2013.05.16 14:01

      감사합니다.
      이제 돌아가는걸 볼 수 있겠네요.

  3. truelifer 2013.10.10 17:13

    좋은 포스팅 감사합니다.
    저도 한가지 의문이 있는건, bluepoet 님이 문의하신 것 처럼 동일 키로 요청이 오면 어떻게 체인의 앞에서 발견되도록 하는건지 궁금합니다.
    코드를 보니 previousCaches 를 따로 저장하지 않고 getBitmap() 함수가 끝나면 previousCaches 가 소멸될 것 같은데..

    • 최범균 madvirus 2013.10.11 09:09 신고

      previousCaches 라는 로컬 변수는 사라지지만, previousCache에 담은 객체는 chain 필드에 보관되어 있는 ImageCache 입니다.
      getBitmap() 메서드를 보시면, chain에 있는 ImageCache를 차례대로 탐색하면서 해당 ImageCache가 key에 해당하는 비트맵을 갖고 있는 지 확인을 합니다. 갖고 있으면, for 루프를 나오고, 아니면 (로컬 변수인) previousCaches에 ImageCache를 추가합니다.

      발견된 Bitmap이 없으면 그냥 null을 리턴하고, 발결된 Bitmap이 있으면, previousCaches에 담아 두었던 ImageCache들의 addBitmap을 호출해서 비트맵을 추가해줍니다. 여기서 previousCaches에는 chain에서 비트맵이 발견되기 전까지의 ImageCache 목록을 갖고 있게 되죠. 따라서, chain의 특정 ImageCache에서 비트맵이 발견되면, 그 ImageCache 이전에 있던 ImageCache들에 비트맵을 추가하게 됩니다.

  4. winterCha 2013.10.17 16:38

    정말 감사합니다 아주 투통이 사라지는 캐쉬 로직이네요
    태클아닌 태클 하나.....

    checkAlreadyExists 를 표현 하고 싶으신것 같은데 r이 빠졌네요
    쏘리욤

    madvirus fan 입니다 최범균님 책으로 참 많은 것을 공부 했었던 기억이

    존경합니다.

    • 최범균 madvirus 2013.10.18 15:30 신고

      고맙습니다. 실은 제가 그 단어를 잘 틀려요. 발음을 잘못해서 자꾸 빼 먹는 것 같습니다.

  5. cs만두 2014.05.19 22:22 신고

    안녕하세요 안드로이드에 관심이 많은 대학생 개발자입니다!
    몇달전 프로젝트를 진행하면서 bitmap OOM관련 이슈때문에 고민을 하다가 AUIL 라이브러리를 사용해서 해결했습니다. 그러면서 꼭 시간나면 이미지 로더 관련해서 공부해봐야겠다 생각했는데 이렇게 좋은 글을 만나서 너무 좋습니다.
    좋은 글 감사합니다! 열심히 공부하겠습니다!!!!!!

  6. 이승화 2014.05.23 16:59

    감사합니다. 덕분에 좀더 쉽게 코딩할수 있게 되었습니다.
    책도 많이 쓰셨던데 짱~ 이십니다ㅋ

  7. 최형식 2015.03.25 14:07

    지도 앱을 만들고 있습니다. svg파일로 하든 png로 하든 비트맵으로 변경해서 보여주려고 합니다. 사이즈가 크다 보니 타일처럼 짤라서 화면밖의 타일들은 캐시로, 이미지가 움직이면 캐시에서 불러오고 이런식으로 하고싶은데 어떤 방법이 있나요?

  8. 최수혁 2015.05.22 11:03

    안녕하세요?
    이 글 보고 안드로이드 이미지 캐시를 완벽하게 구현하였습니다.
    그런데 문제가 하나 있습니다.

    A라는 이름으로 캐시를 하나 창조해서 다운로드 해서 이미지를 모두 넣었습니다.
    그 다음 액티비티에서 B라는 이름으로 캐시를 하나 또 창조했는데 다운로드되지 않고 있습니다.
    캐시창조할때에는 모두 오류가 없는데 이상하게 onResult에는 안들어오네요..

    B와 A의 순서를 바꾸어도 역시 2번째는 작동을 안합니다.

    왜그럴가요?

    도저히 원인을 못찾겠네요.

    • 최범균 madvirus 2015.05.23 00:47 신고

      코드를 바꾸지 않으셨다면, 코드에 버그가 있는 거겠죠. 지금은 졸려서 코드가 눈에 잘 안 들어오네요. 좀 맨 정신에 코드 보고 답글 달아보렵니다.

+ Recent posts