2020년 9월 7일 월요일

Docker 이미지 만들기 (Dockerfile 작성하기)

 Docker 이미지를 만들기 위해서 간단히 알고 가야 할 것들을 정리해 볼까 한다. 아주 빈번하게 필요한 작업이 아니다 보니 매번 기억을 살려내기 위해서 문서를 찾고 읽고하는 작업을 최소화(?)하기 위해서 간단한 build 명령 사용법과 Dockerfile 명령어들을 정리해 보려고 한다. 나중에 필요한 때가 오면 이 글만 쓰~윽 읽고 바로 작업할 수 있도록...


docker build 명령어 사용


 쓸만한 옵션을 정리해보면 대략 아래 정도가 될 것이다.
$ sudo docker build [OPTIONS] {PATH}
 -f {docker-file}
 -t {repository}:{tag}
{PATH}는 local filesystem 경로이다. Docker 이미지를 생성하는동안 Context가 된다.
'-f'옵션은 생략되면 '{PATH}/Dockerfile'이 사용된다.
'-t'옵션의 {repository}는 'image'이라고도 한다. Docker 이미지는 repository를 통해서 식별되고 배포되기 떄문에 감안하여 이해하도록 하자.

아래는 간단한 명령 예제이다. 참고하도록 하자.
$ sudo docker build .
$ sudo docker build -f /path/to/a/Dockerfile .
$ sudo docker build -t shykes/myapp .
$ sudo docker build -t shykes/myapp:1.0.2 -t shykes/myapp:latest .
$ sudo docker build -t test/myapp .


Dockerfile 명령어


 Dockerfile의 내용은 아래와 같은 포맷으로 된 명령들의 나열이다. 매우 간단하다.
# Comment
INSTRUCTION arguments

FROM

 Dockerfile의 맨 앞에 나오는 명령이다. base가 되는 이미지를 지정한다.
FROM [--platform=<platform>] <image>[:<tag>] [AS <name>]

FROM ubuntu:latest
FROM node:12
FROM python:3.8-alpine

WORKDIR

컨테이너상에서 작업 디렉토리를 지정한다. 로컬의 경로가 아니라 컨테이너에서의 작업 디렉토리이다.
WORKDIR /path/to/workdir

WORKDIR /usr/app
WORKDIR /usr/local/tomcat

COPY

호스트에 있는 디렉터리나 파일을 Docker 이미지의 파일 시스템으로 복사하기 위해서 사용된다. 상대 경로를 사용하는 경우에는 WORKDIR 명령으로 설정한 경로를 고려하여 지정해야 한다. 경로에 공백문자가 포함된 경우에는 JSON array 형태로 인자를 지정하도록 한다.
COPY [--chown=<user>:<group>] <src>... <dest>
COPY [--chown=<user>:<group>] ["<src>",... "<dest>"]

COPY hom* /mydir/
COPY test.txt relativeDir/
COPY test.txt /absoluteDir/

ADD

COPY 명령과 거의 같지만 압축 파일이나 네트워크 상의 파일들을 이미지에 복사해 넣을 수 있다.
ADD [--chown=<user>:<group>] <src>... <dest>
ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]

ADD hom* /mydir/
ADD test.txt relativeDir/
ADD test.txt /absoluteDir/

RUN

직전까지 생성된 이미지 Layer 위에서 명령을 실행한다. 명령이 실행된 결과는 다음 Layer에 반영될 것이다.
RUN <command>
RUN ["executable", "param1", "param2"]

RUN /bin/bash -c 'source $HOME/.bashrc; echo $HOME'
RUN ["/bin/bash", "-c", "echo hello"]

CMD

CMD 명령문은 해당 이미지를 컨테이너로 띄울 때 디폴트로 실행할 커맨드나, ENTRYPOINT 명령문으로 지정된 커맨드에 디폴트로 넘길 파라미터를 지정할 때 사용한다. Dockerfile에 CMD 명령은 하나만 있어야하며 여러 개가 존재하는 경우 마지막 CMD명령만 유효하다.
CMD ["executable","param1","param2"]  (exec form)
CMD ["param1","param2"]  (as default parameters to ENTRYPOINT)
CMD command param1 param2  (shell form)

CMD echo "This is a test." | wc -
CMD ["/usr/bin/wc","--help"]

ENTRYPOINT

ENTRYPOINT는 컨테이너를 실행할 때 컨테이너에서 실행될 명령을 지정한다. 컨테이너를 마치 실행파일을 실행하는 것처럼 동작하도록 만들기 위해서 사용한다.
ENTRYPOINT ["executable", "param1", "param2"]  (exec form)
ENTRYPOINT command param1 param2  (shell form)

ENTRYPOINT top -b
ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]

ENV

ENV 명령은 컨테이너상에 환경변수를 설정하기 위해서 사용한다.
ENV <key> <value>
ENV <key>=<value> ...

ENV myName John Doe
ENV myName="John Doe" myDog=Rex\ The\ Dog \
    myCat=fluffy

VOLUME

VOLUME 명령은 컨테이너에 mount point를 생성하기 위해서 사용한다. 컨테이너를 실행할 때 제공되는 host의 filesystem을 mount하기 위함이다.
VOLUME ["/var/log/"]
VOLUME /var/log

EXPOSE

EXPOSE 명령은 docker에게 컨테이너가 특정 포트를 listen하고 있음을 알리기 위해서 설정한다. 실제로 포트에 대해서는 아무런 작업도 없고 컨테이너를 실행할 때 '-p' 또는 '-P'옵션으로 실행을 해야한다.
EXPOSE <port> [<port>/<protocol>...]

EXPOSE 80
EXPOSE 80/udp

2019년 6월 4일 화요일

Android에서 AWS S3 파일 다운로드하기

 최근에 AWS를 많이 이용하면서 S3 서비스에 크기가 큰 미디어 파일을 올려놓고 앱에서 다운로드를 해야하는 작업을 하게 되었다. 단순히 S3에 파일을 올려놓고 Public access가 가능하도록 하면 좋겠지만 그런 일이라면 웹서버에서 다운로드를 하는 것과 마찬가지이기 때문에 특별히 설명할 일이 없다.

 이번 글에서 설명하려고 하는 것을 간단히 정리하자면 Android 단말에서 AWS Mobile SDK를 이용하여 S3에 올려놓은 파일을 다운로드 하는 방법이다. 여기서는 다운로드에 대해서만 설명하지만 업로드 역시 같은 원리도 동작하므로 과정을 제대로 이해한다면 업로드도 간단하게 구현할 수 있을 뿐만 아니라 S3가 제공하는 여러가지 가능한 작업들에 대해서도 어렵지 않게 구현할 수 있다는 것을 알게 될 것이다. 그러므로 여기서는 다운로드에 대해서만 확실히 짚고 넘어가도록 하자.

 먼저 전제적인 작업 과정을 간단히 설명하겠다.

  1. Cognito 서비스를 이용해서 "자격 증명 풀"을 생성한다.
  2. 생성된 "자격 증명 풀"에 S3에 접근할 수 있는 권한을 부여한다.
  3. 안드로이드에서 "자격 증명 풀"을 이용(인증을 수행)할 CredentialsProvider 객체와 S3에 파일을 송수신할 TransferUtility 객체를 생성한다.
  4. 다운로드를 실행한다.
 보다시피 간단한 작업이지만 당장은 이해가 가지 않을 것이므로 하나 씩 살펴보도록 하자.

 먼저 S3의 버킷은 이미 생성되어 있다고 가정하고 진행하도록 하겠다. 버킷 생성은 어려운 일도 아니거니와 관련 글도 쉽게 찾을 수 있으니 따로 설명은 하지 않겠다. 이 글에서는 "Bucket_Name"이라는 이름을 가진 버킷이 있다고 가정한다.

Cognito 자격 증명 풀 생성


 AWS Management Console에 접속하여 Cognito 서비스 콘솔로 들어가도록 하자.

[그림1] Cognito 첫 화면
 처음 Cognito에 들어가면 위와 같은 화면이 보인다. "자격 증명 풀 관리" 버튼을 눌러서 들어가도록 하자. "자격 증명 풀"이 하나도 없으면 아래와 같은 자격 증명 풀 생성 화면이 바로 보인다. 이미 생성된 "자격 증명 풀"이 있다면 자격 증명 풀 목록 화면(아래 [그림5])이 보일 것이고 그 화면에는 "새 자격 증명 풀 만들기" 버튼이 있다. 버튼을 눌러서 새 "자격 증명 풀"을 만들어 보자.

[그림2] 자격 증명 풀 만들기 - 1
 위와 같은 화면이 나오면 "자격 증명 풀 이름"을 입력하고 "인증되지 않은 자격 증명에 대한 액세스 활성화"를 체크하도록 하자. 하나의 자격 증명 풀은 하나의 앱 사용자들이 인증을 위해 사용하게 될 것이므로 해당 앱과 관련된 이름으로 하는 게 좋다. "인증되지 않은 자격 증명에 대한 액세스를 활성화"를 체크해 주어야 로그인 과정을 거치지 않은 앱 사용자들이 S3에 액세스할 수 있게 된다. 따로 로그인 과정이 없는 앱이거나 앱의 로그인을 Firebase와 같은 다른 서비스를 이용해서 구현한다면 반드시 활성화해야 한다. 많은 경우 이러한 형태를 사용할 것이고 여기서도 로그인 과정은 없을 것이므로 체크를 하고 넘어가도록 하자. 입력이 완료되었으면 "풀 생성" 버튼을 눌러서 다음으로 진행하자.

[그림3] 자격 증명 풀 만들기 - 2
 다음 나오는 화면은 권한을 설정하는 화면이다. 기본적으로 세부 정보가 숨겨진 상태로 보이겠지만 위 화면과 같이 펼쳐서 확인해보면 2개의 IAM 역할이 생성된다는 것을 확인할 수 있다. 따로 설정할 건 없으니 새로 생성되는 역할 2개의 이름만 간단히 확인하고 "허용" 버튼을 눌러서 "자격 증명 풀" 생성을 완료하도록 하자.

[그림4] 자격 증명 풀 만들기 완료
 생성 완료가 되면 위와 같은 화면이 보인다. 정보를 표시하는 화면이므로 한 번 훑어 보도록 하자. SDK 다운로드 버튼이 있지만 Gradle에서 필요한 라이브러리파일은 알아서 받을 것이므로 여기서 다운로드를 할 필요는 없다.

 "AWS 자격 증명 얻기" 샘플 코드가 있다. 이 코드는 그대로 실제 코드에서 쓰이게 될 것이다. 참고로 이 화면은 언제든지 자격 증명 풀 상세화면에서 확인할 수 있으므로 특별히 관리를 위해서 따로 보관해야 할 필요는 없다.

 이제 Cognito "자격 증명 풀 관리" 화면으로 가보면 아래와 같이 생성된 자격 증명 풀을 볼 수 있다.

[그림5] 자격 증명 풀 목록 화면
 생성된 "자격 증명 풀"을 선택해서 들어가면 샘플 코드를 비롯하여 여러가지 정보를 확인 및 수정할 수 있으므로 한 번 들어가서 어떤 정보들이 있는 지 확인해보는 게 좋겠다.

 이것으로 Cognito 자격 증명 풀 생성은 끝났다. 이제 IAM으로 가서 설정을 계속하자.

IAM 역할 권한 설정


 자격 증명 풀을 생성하고나면 자격 증명 풀이 사용하는 IAM 역할 2개가 생성된다. 이 역할에 S3 접근이 가능한 권한을 부여함으로써 앱 사용자들의 S3 접근을 가능토록 하는 것이다.

 먼저 IAM 서비스 콘솔로 들어가도록 하자.

[그림6] IAM 콘솔 대시보드
 IAM 콘솔에 접속하면 위와 같은 화면이 나온다. 왼쪽의 메뉴에서 "역할"을 눌러서 들어가도록 하자.

[그림7] IAM 역할 목록
 역할 목록에서 Cognito 자격 증명 풀에서 생성된 역할 두 개를 확인할 수 있다. (이름에 TestApp이 들어간 역할 두 개를 확인할 수 있다) 하나는 이름이 "~~~Auth_Role"이고 다른 하나는 "~~~Unauth_Role" 이다. 이름에서 대충 짐작 하겠지만 Auth_Role 은 인증된 자격 증명을 위한 역할이고 Unauth_Role은 인증되지않은 자격 증명을 위한 역할이다. 우리는 인증되지 않은 자격 증명을 이용할 것이므로 ~~~Unauth_Role을 눌러서 들어가도록 하자.

[그림8] 역할 상세 정보
 역할을 클릭하여 들어가면 선택된 역할의 상세 정보가 위 화면과 같이 표시된다. 추가 권한을 부여하기 위해서 화면 우측의 "인라인 정책 추가"를 눌러서 새로운 정책을 추가한다.

[그림9] 인라인 정책 생성
 인라인 정책 추가 버튼을 눌러서 들어오면 위와 같은 화면이 나타난다. 아직 아무런 설정도 되지않은 상태의 화면이다. 생성할 정책이 어떤 서비스에서 어떤 작업을 할 것이며 어떤 리소스를 대상으로 할 것인지 여기서 지정할 것이다. 빨간색으로 표시된 부분을 하나씩 클릭해 가면서 정보를 채우면 된다.

 먼저 대상 서비스는 S3이므로 "서비스"를 클릭하여 S3를 입력하도록 하자. 별로 설명할 게 없으므로 화면은 따로 넣지 않겠다.

 다음 "작업"을 클릭하여 어떤 작업을 할 것인지 설정하도록 하자.
[그림10] 정책 작업 설정
 "작업"을 클릭하면 S3에서 허용되는 작업들이 표시되는데 보안을 위해서 가능한 엄격하게(최소한으로) 액세스할 수 있도록 설정한다. 우리는 S3에서 파일을 다운로드할 것이므로 "읽기 > GetObject"에만 체크하였다. 업로드를 하려면 "쓰기 > PutObject"도 체크해야 할 것이다. 권한 이름을 자세히 들어다보면 웬만한 건 어떤 작업들인지 대충 감이 온다. 자세한 사항은 AWS 문서를 참조하도록 하자.

[그림11] 대상 리소스 설정
 다음으로 "리소스"를 클릭하면 위와 같은 설정 화면이 나온다. 앱이 S3의 모든 리소스에 접근할 것은 아니므로 "특정"을 선택하고 "ARN 추가"를 클릭해준다.

[그림12] 대상 리소스 설정
 원하는 버킷의 이름과 Object(파일) 이름을 넣을 수 있다. 위 그림의 설정은 일반적으로 많이 사용하는 패턴의 예로 특정 버킷을 앱에서 접근가능하게 하고 그 안의 모든 파일에 대해서 접근이 되도록 설정하고 있다. 버킷도 "모두 선택"을 체크하면 모든 버킷에 접근하는 것도 가능하다. 추가 버튼을 눌러서 ARN을 추가하도록 하자.

[그림13] 정책 설정 완료
 마지막 "요청 조건"은 기본 값 그대로 사용한다. 클릭해서 어떤 조건들이 설정가능한 지 확인해 보는 것도 좋겠다. 일반적으로 쓸 만한 조건들은 아니다. 마지막으로 "정책 검토" 버튼을 눌러서 다음으로 진행하자.

[그림14] 정책 검토
 마지막 정책 검토 화면이다. 생성될 정책의 이름을 설정하고 정책 설정 상태를 확인할 수 있다. 적당히 원하는 이름을 입력하고 "정책 생성" 버튼을 눌러서 완료하도록 하자.

[그림15] 정책 설정 완료
 위에서 설정한 IAM 역할의 상세화면이다. 새로 생성한 S3 접근 정책이 추가로 설정된 것을 확인할 수 있다.

 AWS에서의 설정은 이것으로 완료되었다. Android 코드에서는 Cognito 인증만 정상적으로 수행된다면 S3에 접근할 수 있는 권한이 생기게 된다. 이제 Android 코드를 보도록 하자.

S3 Downlaod 구현 


 Android 소스코드는 Kotlin 으로 작성되었다. 혹시 Java로 작업중이더라도 조금만 자세히 들여다보면 대충 어떤 코드인지 알 수 있을 것이다. 위에서 설정중에 제공되는 샘플 코드는 Java 코드지만 붙여넣기를 하면 자동으로 변환될 것이므로 별로 문제 될 건 없다.

 먼저 build.gradle 파일을 열어서 dependency를 추가하도록 하자.

...
dependencies {
    ...
    implementation 'com.amazonaws:aws-android-sdk-mobile-client:2.13.5'
    implementation 'com.amazonaws:aws-android-sdk-cognito:2.13.5'
    implementation 'com.amazonaws:aws-android-sdk-s3:2.13.5'
    ...
}
...

 dependency 설정이 되었으면 다음으로 AndroidManifest.xml 파일에 몇 가지 설정을 해야 한다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" ...>
    ...
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application ...>

        <activity ... >
            ...
        </activity>

        <service android:name="com.amazonaws.mobileconnectors.s3.transferutility.TransferService" android:enabled="true" />

    </application>
    ...
</manifest>

 위 예를 참고하여 AndroidManifest.xml 파일을 수정하도록 하자. 중요한 건 user-permission과 service 설정이다. user-permission은 network 작업과 storage 액세스를 위한 일반적인 것들이므로 안드로이드 개발자라면 당연히 알만한 것들이므로 따로 설명하진 않겠다. 주의해야 할 것은 service 설정이다. 파일 전송을 위해서 이 service 선언이 반드시 추가되어야 한다. 이름으로 봐선 전송작업을 background로 처리하기 위한 service인 것 같다. 실수로 빼먹었다가 크게 고생할 수 있으므로 신경써서 넣어주도록 하자.

class MainActivity : BaseActivity() {

 ...
 
    fun onDownloadClick(v: View) {
        downloadWithTransferUtility()
    }

    private fun downloadWithTransferUtility() {
        // Cognito 샘플 코드. CredentialsProvider 객체 생성
        val credentialsProvider = CognitoCachingCredentialsProvider(
            applicationContext,
            "ap-northeast-2:167efb36-dea5-4724-935d-0c419fc48f12", // 자격 증명 풀 ID
            Regions.AP_NORTHEAST_2 // 리전
        )

        // 반드시 호출해야 한다. 
        TransferNetworkLossHandler.getInstance(applicationContext)

        // TransferUtility 객체 생성
        val transferUtility = TransferUtility.builder()
            .context(applicationContext)
            .defaultBucket("Bucket_Name") // 디폴트 버킷 이름.
            .s3Client(AmazonS3Client(credentialsProvider, Region.getRegion(Regions.AP_NORTHEAST_2)))
            .build()

        // 다운로드 실행. object: "SomeFile.mp4". 두 번째 파라메터는 Local경로 File 객체.
        val downloadObserver = transferUtility.download("SomeFile.mp4", File(filesDir.absolutePath + "/SomeFile.mp4"))

        // 다운로드 과정을 알 수 있도록 Listener를 추가할 수 있다.
        downloadObserver.setTransferListener(object : TransferListener {
            override fun onStateChanged(id: Int, state: TransferState) {
                if (state == TransferState.COMPLETED) {
                    Log.d("AWS", "DOWNLOAD Completed!")
                }
            }

            override fun onProgressChanged(id: Int, current: Long, total: Long) {
                try {
                    val done = (((current.toDouble() / total) * 100.0).toInt()) //as Int
                    Log.d("AWS", "DOWNLOAD - - ID: $id, percent done = $done")
                }
                catch (e: Exception) {
                    Log.d("AWS", "Trouble calculating progress percent", e)
                }
            }

            override fun onError(id: Int, ex: Exception) {
                Log.d("AWS", "DOWNLOAD ERROR - - ID: $id - - EX: ${ex.message.toString()}")
            }
        })
    }
}

 MainActivity에 Download 버튼을 넣고 버튼을 클릭하면 다운로드를 수행하는 샘플코드이다. downloadWithTransferUtility() 함수가 전체 다운로드 코드를 담고 있으므로 이 함수를 자세히 보도록 하자.

 가장 먼저 CredentialsProvider 객체를 생성한다. Cognito 자격 인증 풀을 생성할 때 제공되는 샘플코드를 붙여넣기 하면 된다.

 다음으로 TransferUtility 객체를 생성한다. 버킷 이름과 S3 리전 정보를 적절히 입력하면 된다. 만약 접근 레벨을 설정할 때 모든 버킷을 대상으로 지정했다면 빈 문자열을 defaultBucket()에 지정하고 transferUtility.download()를 호출할 때 대상 객체(첫 번째 인자)를 "Bucket_Name/SomeFile.mp4"와 같이 설정해도 정상적으로 다운로드가 실행된다. 하지만 일반적으로는 다운로드할 파일이 있는 버킷을 지정하면 된다.

 마지막으로 transferUtility.download() 함수를 호출하면 다운로드를 시작한다. 첫 번째 파라메터가 객체의 이름이고 두 번째 파라메터는 로컬에 다운로드할 경로의 File 객체이다. 이 함수는 TransferObserver 객체를 반환하는데 이 객체에 TransferListener를 추가하여 다운로드 이벤트 콜백을 받을 수 있다. 인터페이스의 함수 이름과 코드를 보면 대충 어떤 작업을 위한 것인지 알 수 있을 것이다.

 이로써 S3에서 파일을 다운로드하는 방법에 대한 글을 마치도록 하겠다. 복잡한 느낌이 많이 들지만 각종 설정과 용어들 때문이지 결코 코드는 복잡하지 않다. 사실 이 글을 남기기로 한 이유도 얼마 안가서 다 잊어버릴 것 같은 느낌이 강하게 들었기 때문이다. 분명 이 과정을 반복하는 개발자들이 있을 것이다. 그 분들에게 많은 도움이 되었으면 한다.

2019년 5월 18일 토요일

Thymeleaf 간단 매뉴얼

 웹 개발 시에 사용할 수 있는 Template Engine으로 JSP를 많이 사용하지만 직접 사용해보니 JSP보다 Thymeleaf가 훨씬 나은 듯 하다. 기본적인 문법도 간단하고 template 파일을 그대로 브라우저에서 열어봐도 문제가 없도록 template을 작성할 수 있다는 점도 내게는 큰 장점으로 보인다. 그래서 여기에 기본적인 문법을 간단한 샘플과 함께 정리해 보려고 한다.
 Thymeleaf를 처음 배우는 사람에게는 아무 것도 이해가지 않을 내용이지만 새롭게 기억을 되살려 보려고 한다면 큰 도움이 될 것이라고 생각된다. 모든 내용을 정리할 수는 없지만 많이 사용하는 기능들 위주로 최대한 성의껏 작성해 보겠다. 참고로 이 글에서 사용하는 대부분의 샘플 코드는 Thymeleaf의 공식 문서에서 발췌된 것임을 밝힌다.

1. Message Expression: #{ ... }


...
<p th:text="#{home.welcome}">Welcome to our grocery store!</p>
...
 message source로부터 home.welcome 키에 지정된 메시지를 가져와서 해당 tag의 text를 대체한다. th:text는 태그의 text를 지정된 값으로 대체하는 기능을 한다. 예를 들어 messages.properties에 아래와 같은 값이 있다면
...
home.welcome=¡Bienvenido a nuestra tienda de comestibles!
...
 아래와 같은 결과를 얻게될 것이다.
...
<p>¡Bienvenido a nuestra tienda de comestibles!</p>
...
 만약 문자열의 내용을 escape처리하고 싶지 않다면 th:utext를 사용한다. '<' 기호가 "&lt;" 로 변환되지 않고 그대로 남아있게 된다.
...
<p th:utext="#{home.welcome}">Welcome to our grocery store!</p>
...
 message 문자열이 아래와 같이 파라메터를 받도록 되어있다면
...
home.welcome=¡Bienvenido a nuestra tienda de comestibles, {0}!
...
 아래와 같이 괄호를 넣어서 파라메터를 넘길 수 있다.
...
<p th:utext="#{home.welcome(${session.user.name})}">
  Welcome to our grocery store, Sebastian Pepper!
</p>
...

2. Variable Expression: ${ ... }


...
<p>Today is: <span th:text="${today}">13 february 2011</span>.</p>
...
 모델에 저장된 today 속성값을 문자열로 변환(toString())하여 span tag의 text값을 대체한다.
 아래의 예제와 같이 .을 이용하여 속성값에 접근하거나 []을 이용하여 Map이나 Array의 원소에 접근하는 것들도 가능하고 객체의 메소드 호출도 가능하다.
${person.father.name}
${person['father']['name']}

${countriesByCode.ES}
${personsByName['Stephen Zucchini'].age}

${personsArray[0].name}

${person.createCompleteName()}
${person.createCompleteNameWithSeparator('-')}
 참고로 ${{ ... }}형태의 문법(double-brace syntax)이 있는데 이 문법은 최종적으로 산출된 결과 객체를 등록된 Conversion Service를 이용해서 formatting하도록 한다. 스프링과 함께 사용할 때는 스프링의 Conversion Service를 사용하므로 스프링에 익숙하다면 상당히 편리한 기능이다.

3. Selection Variable Expression: *{ ... }


 *{...} 표현식은 부모 태그의 th:object에 지정된 객체를 기준으로 해당 객체의 속성에 접근한다. 아래 예제를 보자.
...
<div th:object="${session.user}">
    <p>Name: <span th:text="*{firstName}">Sebastian</span>.</p>
    <p>Surname: <span th:text="*{lastName}">Pepper</span>.</p>
    <p>Nationality: <span th:text="*{nationality}">Saturn</span>.</p>
</div>
...
 위의 코드는 아래 코드와 정확히 동일하다.
...
<div>
  <p>Name: <span th:text="${session.user.firstName}">Sebastian</span>.</p>
  <p>Surname: <span th:text="${session.user.lastName}">Pepper</span>.</p>
  <p>Nationality: <span th:text="${session.user.nationality}">Saturn</span>.</p>
</div>
...
 지정된 객체(th:object)가 없는 경우 ${...}와 *{...}는 완전히 동일한 기능을 하며 지정된 객체가 있는 경우에도 ${ ... } 표현식을 섞어서 사용하는 데 문제가 없다.

 이 표현식도 역시 *{{ ... }}형태의 문법(double-brace syntax)을 지원한다. 즉, 결과 객체를 Conversion Service를 이용하여 변환시켜주므로 참고하도록 하자.

4. Link URL Expression: @{ ... }


 th:href, th:src, th:action 등과 같이 URL이 지정되는 속성에 사용한다. 아래 예제와 같이 여러가지 형태의 URL을 지정할 수 있다.
<!-- Page-relative URL. 일반적인 상대 경로 URL  -->
<a href="login.html" th:href="@{user/login.html}">login</a>

<!-- Context-relative URL. context name이 URL 앞에 자동으로 붙어서 생성된다. -->
<a href="login.html" th:href="@{/login.html}">login</a>

<!-- Server-relative URL. 서버의 다른 context에 접근할 수 있다. -->
<a href="login.html" th:href="@{~/other/login.html}">login</a>

<!-- Protocol-relative URL. -->
<a href="login.html" th:href="@{//www.other.com/login.html}">login</a>
 괄호를 사용해서 파라메터를 넘겨서 URL 파라메터를 지정하거나 URL path를 구성하는데 사용할 수 있다.
<!-- '/gtvg/order/details?orderId=3'을 생성. URL 파라메터로 사용됨. -->
<a href="details.html" th:href="@{/order/details(orderId=${o.id})}">view</a>

<!-- '/gtvg/order/3/details'를 생성. orderId를 local변수로 사용하여 URL path를 생성. -->
<a href="details.html" th:href="@{/order/{orderId}/details(orderId=${o.id})}">view</a>

5. Literals & Operators


 Thymeleaf의 Literal과 연산자들은 아래와 같다. 일반적인 개념에서 크게 벗어나는 것은 없으므로 추가 설명은 하지 않겠다.

  1. Literals
    • Text literals: 'one text', 'Another one!',…
    • Number literals: 0, 34, 3.0, 12.3,…
    • Boolean literals: true, false
    • Null literal: null
    • Literal tokens: one, sometext, main,…
  2. Text operations
    • String concatenation: +
    • Literal substitutions: |The name is ${name}|
  3. Arithmetic operations
    • Binary operators: +, -, *, /, %
    • Minus sign (unary operator): -
  4. Boolean operations
    • Binary operators: and, or
    • Boolean negation (unary operator): !, not
  5. Comparisons and equality
    • Comparators: >, <, >=, <= (gt, lt, ge, le)
    • Equality operators: ==, != (eq, ne)
  6. Conditional operators
    • If-then: (if) ? (then)
    • If-then-else: (if) ? (then) : (else)
    • Default: (value) ?: (defaultvalue)

6. Attribute 값 설정하기


 th:attr을 이용해서 어떤 attribute든지 값을 설정할 수 있다. tag에 이미 설정되어 있는 값은 th:attr에서 지정한 값으로 대체된다.
<form action="subscribe.html" th:attr="action=@{/subscribe}">
  <fieldset>
    <input type="text" name="email" />
    <input type="submit" value="Subscribe me!" th:attr="value=#{subscribe.submit}"/>
  </fieldset>
</form>
 위 예제는 Processing후에 아래와 같이 될 것이다.
<form action="/gtvg/subscribe">
  <fieldset>
    <input type="text" name="email" />
    <input type="submit" value="¡Suscríbeme!"/>
  </fieldset>
</form>
 하지만 html의 각 attribute에 대해서는 Thymeleaf에도 일대일 대응되는 th:xxx 형태의 속성이 있기 때문에 th:attr은 거의 사용하지 않는다. 이 경우 아래 예제와 같이 값을 지정한다. th:attr과 달리 "name=" 부분은 따로 지정할 필요가 없기때문에 훨씬 간단하게 표현된다.
<form action="subscribe.html" th:action="@{/subscribe}">
 checkbox 타입 버튼의 checked 속성이 check 상태를 표시하는데 이와 같이 boolean값의 역할을 하는 속성의 경우 값이 없이 속성만 지정되면 true의 역할을 하게된다. (값이 "checked"로 설정되어도 된다.) 이런 속성들은 th:checked 속성에 지정된 조건을 평가하여 true로 판단되는 경우 checked 속성이 남게되고 false인 경우 checked 속성자체가 사라지게 된다.
<input type="checkbox" name="active" th:checked="${user.active}" />
 위 예제에서 ${user.active}가 true냐 false냐에 따라 각각 아래와 같이 생성될 것이다.
<input type="checkbox" name="active" checked="checked" />
<input type="checkbox" name="active" />

7. Iteration


 Array나 List같은 Collection 객체를 iteration하는 간단한 예제는 아래와 같다.
...
<table>
  <tr>
    <th>NAME</th>
    <th>PRICE</th>
    <th>IN STOCK</th>
  </tr>
  <tr th:each="prod : ${prods}">
    <td th:text="${prod.name}">Onions</td>
    <td th:text="${prod.price}">2.41</td>
    <td th:text="${prod.inStock}? #{true} : #{false}">yes</td>
  </tr>
</table>
...
 th:each를 이용해서 원소의 갯수만큼 반복할 수 있으며 Iterable 구현 객체, Map 구현 객체 (이 경우 java.util.Map.Entry객체), Array 등의 객체를 사용할 수 있다.
 참고로 위 예제에서 prod 변수 이름에 Stat을 붙여서 'prodStat'이라는 Interaion 상태를 저장하는 로컬 변수가 자동으로 하나 생성되는데 이 변수에는 다음과 같은 상태값들이 저장된다.

  1. prodStat.index: 현재 index. (0부터 시작)
  2. prodStat.count: 현재 index. (1부터 시작)
  3. prodStat.size: 전체 원소 갯수
  4. prodStat.current: 현재 원소 객체. (prod와 같음)
  5. prodStat.even/odd: 현재 iteration이 짝수/홀수 인지를 나타내는 boolean값.
  6. prodStat.first: 현재 iteration이 첫번째 인지를 나타내는 boolean값.
  7. prodStat.last: 현재 iteration이 마지막 인지를 나타내는 boolean값.
 'prodStat' 변수의 이름을 다른 이름으로 지정하려면 <tr th:each="prod, iterStat : ${prods}">와 같이 prod 뒤에 comma를 찍고 이름을 써주면 된다.

8. Conditional Evaluation


 th:if를 이용하면 지정된 식의 결과 값에 따라 tag 전체를 없애거나 표시되도록 할 수 있다. 아래 예제의 <a> 태그는 prod.comments가 비어있지 않으면(not empty) 표시되고 그렇지 않으면 삭제된다.
...
<a href="comments.html" 
   th:href="@{/product/comments(prodId=${prod.id})}" 
   th:if="${not #lists.isEmpty(prod.comments)}">view</a>
...
th:if와 반대로 평가하는 th:unless도 있다. th:if의 결과식에 not을 적용한 것과 같다. 아래 예제는 위 예제와 동일하게 동작한다. (위의 th:if에는 not이 이미 있으므로 그것을 없앤 것과 같다)
...
<a href="comments.html"
   th:href="@{/comments(prodId=${prod.id})}" 
   th:unless="${#lists.isEmpty(prod.comments)}">view</a>
...

9. Template Layout


 완전한 html 문서가 아닌 html 문서의 일부분을 이루는 코드 조각을 fragment라고 한다. fragment를 선언하는 방법은 아래와 같다. (footer.html)
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

  <body>
  
    <div th:fragment="copy">
      &copy; 2011 The Good Thymes Virtual Grocery
    </div>
  
  </body>
  
</html>
 위 예제는 footer.html 파일에 copy fragment를 선언한 것이다. fragment는 th:fragment를 이용하여 선언한다. 이렇게 선언된 fragment는 아래 예제와 같이 다른 문서의 일부분으로 삽입될 수 있다.
<body>

  ...

  <div th:insert="~{footer :: copy}"></div>
  
</body>
 <div>태그에 footer.html에 선언된 copy fragment를 삽입한다는 뜻이다. ~{ ... } 를 fragment expression이라고 하는데 아래 형태로 사용될 수 있다.

  1. "~{ templatename :: selector }"
  2. template 파일의 이름과 그 안에 선언된 Markup selector를 참조한다. 사실 Markup selector는 CSS selector와 비슷한 문법이 있지만 fragment를 참조하는 경우에는 fragment 이름만 써주면 된다. 대부분의 경우 이렇게 사용할 것이다. CSS selector와 비슷하기 때문에 <div id="myid" ... > 형태로 선언된 태그를 ~{ template :: #myid } 형태로 참조할 수 있다.
  3. "~{ templatename }"
  4. 전체 template 파일을 참조한다.
  5. "~{ ::selector }" or "~{ this :: selector }"
  6. 같은 파일 내에 있는 selector를 참조한다는 점을 제외하고 1번과 같다. 마찬가지로 selector로는 fragment 이름을 써주면 된다.
 공식적인 문법은 위와 같지만 단순한 fragment 참조의 경우에는 ~{ }를 생략할 수 있다. 아래 예제들은 대부분 ~{ }를 생략한 형태를 사용하고 있으므로 참고하도록 하자.

 선언된 fragment를 삽입하는 방법은 3가지가 있다. th:insert, th:replace, th:include를 사용할 수 있는데 각각의 차이점은 아래 예제를 보면 알 수 있다. 먼저 아래와 같이 fragment가 선언 되어있다고 하자.
<footer th:fragment="copy">
  &copy; 2011 The Good Thymes Virtual Grocery
</footer>
 아래와 같이 각각의 방법으로 fragment를 삽입하면
<body>
  ...

  <div th:insert="footer :: copy"></div>

  <div th:replace="footer :: copy"></div>

  <div th:include="footer :: copy"></div>
  
</body>
 아래와 같은 결과가 생성된다.
<body>
  ...

  <div>
    <footer>
      &copy; 2011 The Good Thymes Virtual Grocery
    </footer>
  </div>

  <footer>
    &copy; 2011 The Good Thymes Virtual Grocery
  </footer>

  <div>
    &copy; 2011 The Good Thymes Virtual Grocery
  </div>
  
</body>
 참고로 문서에서는 th:insert와 th:replace를 사용토록 권장하고 있다.

 fragment는 아래와 같이 선언하여 호출하는 쪽에서 파라메터를 받을 수 있다.
<div th:fragment="frag (onevar,twovar)">
    <p th:text="${onevar} + ' - ' + ${twovar}">...</p>
</div>
 이렇게 선언된 fragment를 호출할 때는 아래와 같이 파라메터를 넣어서 호출하면 된다.
<div th:replace="::frag (${value1},${value2})">...</div>
<div th:replace="::frag (onevar=${value1},twovar=${value2})">...</div>
 만일 위 예제의 두번째 방법으로 호출할 때 fragment에는 선언된 파라메터가 없다면 두 파라메터는 단순히 로컬변수가 되어 참조가 될 수 있다. 즉 아래와 같은 호출과 동일한 호출이 된다.
<div th:replace="::frag" th:with="onevar=${value1},twovar=${value2}">

10. Removing Template


 브라우저로 template 파일을 열어서 볼 때는 보이지만 processing후에는 특정 태그를 삭제하고 싶다면 th:remove를 사용한다. 사용법은 아래 예제와 같다.
<table>
  <tr>
    <th>NAME</th>
    <th>PRICE</th>
    <th>IN STOCK</th>
    <th>COMMENTS</th>
  </tr>
  <tr th:each="prod : ${prods}" th:class="${prodStat.odd}? 'odd'">
    <td th:text="${prod.name}">Onions</td>
    <td th:text="${prod.price}">2.41</td>
    <td th:text="${prod.inStock}? #{true} : #{false}">yes</td>
    <td>
      <span th:text="${#lists.size(prod.comments)}">2</span> comment/s
      <a href="comments.html" 
         th:href="@{/product/comments(prodId=${prod.id})}" 
         th:unless="${#lists.isEmpty(prod.comments)}">view</a>
    </td>
  </tr>
  <tr class="odd" th:remove="all">
    <td>Blue Lettuce</td>
    <td>9.55</td>
    <td>no</td>
    <td>
      <span>0</span> comment/s
    </td>
  </tr>
</table>
 template을 브라우저로 직접 열어보면 2개의 행이 보이겠지만 thymeleaf engine이 처리 후에는 prods의 원소 갯수만큼의 행이 생성되고 마지막 행은 삭제되어 보이지 않게될 것이다.
 th:remove의 값으로 아래와 같은 값들이 지정될 수 있다.

  • all: th:remove가 선언된 태그와 그 자식 태그들도 모두 삭제한다.
  • body: th:remove가 선언된 태그는 그대로 두고 자식 태그들만 삭제한다.
  • tag: th:remove가 선언된 태그만 삭제하고 자식 태그들은 그대로 둔다.
  • all-but-first: 첫 번째 자식 태그만 두고 나머지 자식 태그는 모두 삭제한다.
  • none: 아무것도 삭제하지 않고 그대로 둔다.
 마지막 none의 경우는 th:remove의 값으로 조건 표현식을 사용하여 조건에 따라 삭제를 할지 말지 결정할 때 사용될 수 있다.

11. Local Variables


 Thymeleaf는 특정 시점에 로컬 변수를 선언해서 사용할 수 있다.로컬 변수 선언은 th:with를 사용한다.
<div th:with="firstPer=${persons[0]},secondPer=${persons[1]}">
  <p>
    The name of the first person is <span th:text="${firstPer.name}">Julius Caesar</span>.
  </p>
  <p>
    But the name of the second person is 
    <span th:text="${secondPer.name}">Marcus Antonius</span>.
  </p>
</div>
 로컬 변수는 위와 같이 한 번에 2개 이상을 선언할 수도 있다. 로컬 변수는 자식 태그뿐 아니라 같은 태그 안에서도 사용할 수 있으며 심지어 th:with 선언 안에서도 첫 번째 변수를 두 번째 변수에서 사용해도 문제가 없다.

12. Attribute Precedence


 일반적으로 모든 언어들의 연산자에는 우선순위가 있다. Thymeleaf의 속성들 간에도 우선 순위가 있다. 아래 표를 참고하여 우선순위를 확인하도록 하자.

OrderFeatureAttributes
1Fragment inclusionth:insert
th:replace
2Fragment iterationth:each
3Conditional evaluationth:if
th:unless
th:switch
th:case
4Local variable definitionth:object
th:with
5General attribute modificationth:attr
th:attrprepend
th:attrappend
6Specific attribute modificationth:value
th:href
th:src
...
7Text (tag body modification)th:text
th:utext
8Fragment specificationth:fragment
9Fragment removalth:remove

13. Comments and Blocks


 Thymeleaf의 주석은 여러가지가 존재한다. 각각의 특성에 따라서 브라우저에서 직접 열었을 때는 주석으로 처리되고 processing 중에는 주석이 아닌 코드로 처리되도록 할 수도 있다. 각각에 대해서 살펴보도록 하자.

  • <!-- and -->
  • 일반적인 HTML의 주석이다. 이 주석은 브라우저에서나 thymeleaf에서나 무시된다. 하지만 processing후 삭제되는 것은 아니고 생성된 결과파일에도 그대로 복사되어 표시된다.
  • <!--/* and */-->
  • thymeleaf의 파서에서 주석으로 처리된다. 즉 파서가 template로부터 이 부분을 삭제하고 processing을 하게된다. 위의 HTML 주석과 별 차이가 없어보이지만 특정 부분을 브라우저에서 직접 열었을 때는 보이지만 thymeleaf가 처리중일 때는 보이지 않도록 할 수 있다. 아래 예제를 보면 어떻게 그렇게 할 수 있는지 알 수 있을 것이다..
    <!--/*-->
      <div> ...
      </div>
    <!--*/-->
    
  • <!--/*/ and /*/-->
  • 위와 반대로 브라우저에서 직접 열었을 때는 주석처리되지만 thymeleaf의 파서에서는 주석이 아닌 코드로 인식되도록 하기 위해서 사용한다. thymeleaf의 파서는 단순히 "<!--/*/", "/*/-->" 문자열을 삭제해 버린다. 따라서 그 안에 있던 모든 코드는 그대로 남겨진 채 처리가 이루어진다.
 thymeleaf가 제공하는 모든 th:xxx 속성들은 element가 아니라 attribute이기 때문에 속성을 지정할 tag가 적당치 않은 경우에 곤란할 때가 있다. 이런 경우 사용할 수 있는 태그가 th:block 태그다. 아래 예제처럼 테이블의 행 2개를 반복해야 할 때 사용할 수 있을 것이다.
<table>
  <th:block th:each="user : ${users}">
    <tr>
        <td th:text="${user.login}">...</td>
        <td th:text="${user.name}">...</td>
    </tr>
    <tr>
        <td colspan="2" th:text="${user.address}">...</td>
    </tr>
  </th:block>
</table>
 하지만 th:block 태그는 브라우저에서는 인식하지 못하는 태그이다. 결국 template 파일을 브라우저에서 열었을 때도 그럴 듯하게 보이도록 하려는 원래 의도가 망가질 수 있다. 하지만 바로 앞에서 설명했던 브라우저에서만 주석이 되도록하는 3번째 주석(<!--/*/ ... /*/-->)으로 th:block 태그 부분만 각각 감싸게되면 브라우저에서는 th:block 태그는 안보이게 되므로 원래 의도가 유지될 수 있다.

14. Spring Form


 스프링과 함께 사용할 때 form을 이용하여 데이터를 주고받을 때는 command 객체를 이용하게 된다. 아래 예제를 보자.
<form action="#" th:action="@{/seedstartermng}" th:object="${seedStarter}" method="post">
    ...
</form>
 위 예제는 전형적인 form태그의 예이다. action속성은 반드시 th:action을 사용하도록 하고 th:object에 command 객체를 지정한다.
 th:action은 단순히 action속성을 설정하는 것 이상의 일을 한다. CSRF 공격 등을 대비한 hidden field를 자동으로 넣어주는 등 추가적인 작업이 수행되게끔 해주므로 반드시 th:action을 사용하도록 하자.
 th:object에는 model attribute의 이름만 지정해야 한다. ${seedStarter.name} 같은 값은 유효하지 않다.

 일반적으로 command 객체의 각 속성값들은 form의 control들과 맵핑되어 자동으로 값을 주고 받게 된다. 이를 위해서 th:field 속성을 이용한다. 아래 예제를 보자.
<input type="text" th:field="*{datePlanted}" />
 th:field가 선언된 위 예제는 아래 예제와 비슷하다고 할 수 있다.
<input type="text" id="datePlanted" name="datePlanted" th:value="*{datePlanted}" />
 th:field는 input type에 맞게 적절히 필요한 속성을 알아서 처리해주며 필드의 객체를 표시하는데 Conversion이 필요하다면 알아서 Conversion Service를 이용하여 변환작업도 수행해준다.

15. Validation & Error Messages (String)


 스프링에서 form data는 서버로 전송되어 command 객체와 binding이 이루어지며 이 때 validation이 이루어진다. binding 결과는 bindingResult 객체에 저장되는데 thymeleaf는 이 값을 이용해서 에러가 발생했는지, 어떤 에러가 발생했는지 등등을 알 수 있으며 이 때 에러 메시지를 표시하도록 할 수 있다. 이러한 에러처리를 위한 thymeleaf 기능들에 대해 알아보자.

- #fields.hasErrors('field') : field에 에러가 있다면 true, 아니면 false. 모든 필드를 대상으로 하려면 '*' 또는 'all'을 지정한다. 특정 필드가 아닌 global에러를 검사하려면 'global'을 지정한다.
<input type="text" th:field="*{datePlanted}" 
                   th:class="${#fields.hasErrors('datePlanted')}? fieldError" />
- #fields.errors('field') : field의 모든 에러 메시지를 iteration할 수 있는 iterable 객체 반환. 모든 필드를 대상으로 하려면 '*' 또는 'all'을 지정한다. 특정 필드가 아닌 global 에러 메시지를 가져오려면 'global'을 지정한다.
<ul>
  <li th:each="err : ${#fields.errors('datePlanted')}" th:text="${err}" />
</ul>
- th:errors : 지정된 필드의 모든 에러 메시지를 <br /> 태그로 분리된 문자열로 반환. global 에러 메시지를 가져오려면 *{global}을 지정한다.
<input type="text" th:field="*{datePlanted}" />
<p th:if="${#fields.hasErrors('datePlanted')}" th:errors="*{datePlanted}">Incorrect date</p>
- th:errorclass : th:field에 지정된 필드에 에러가 있는 경우 class 속성에 지정된 값을 추가해 준다. 아래 예제에서 에러가 있다면 class는 "small fieldError"가 될 것이다.
<input type="text" th:field="*{datePlanted}" class="small" th:errorclass="fieldError" />
- #fields.hasAnyErrors() : #fields.hasErrors('*')와 동일.

- #fields.allErrors() : #fields.errors('*')와 동일.

- #fields.hasGlobalErrors() : #fields.hasErrors('global')와 동일.

- #fields.globalErrors() : #fields.errors('global')와 동일.

- #fields.detailedErrors() : 모든 에러에 대한 상세한 정보를 담고 있는 iterable 객체를 반환. 에러 객체는 fieldName(String), message(String), global(boolean) 속성을 갖는다.
<ul>
    <li th:each="e : ${#fields.detailedErrors()}" th:class="${e.global}? globalerr : fielderr">
        <span th:text="${e.global}? '*' : ${e.fieldName}">The field name</span> |
        <span th:text="${e.message}">The error message</span>
    </li>
</ul>

마무리...


 처음 계획은 간단한 설명과 예제로 Thymeleaf 예제 모음 정도의 글을 쓰려고 했는데 쓰다보니 점점 일이 커져서 꽤나 고생해서 작성한 글이 되어버렸다. 사실 위에 정리된 내용 말고도 더 많은 기능들이 있지만 실제 현업에서 사용하는 기능은 이 정도면 90% 이상 커버할 수 있지 않을까 싶다. 사실 설명이 자세하진 않지만 예제가 있으므로 충분히 도움이 될 것으로 믿는다. 내가 나중에 다시 Thymeleaf로 작업을 하게되면 인터넷 검색이 선행되는 일이 없이 이 글만 읽고 바로 작업을 시작할 수 있을 것 같아 조금은 홀가분한 마음이다. Thymeleaf를 선호하는 많은 개발자들에게도 그러하기를 기대해 본다.

2019년 1월 13일 일요일

CentOS 7에 MariaDB 10.3 설치하기

 최근 CentOS 7에 Maria DB를 설치할 일이 생겨서 설치하는 김에 여기에 설치 과정을 정리해 보려고 한다.

 환경에 대해서 간단히 설명하자면 Amazon Lightsail 가상 서버에 CentOS 7을 설치하고 Maria DB 10.3 버전을 설치할 것이다. 현재 Maria DB의 최신 버전은 10.3이다.

Maria DB repository 설정


 https://downloads.mariadb.org/mariadb/repositories/ 에 접속하면 리눅스 배포본에 따라 각 repository로 부터 설치할 수 있는 방법을 제시해준다. 여기서 제시하는 방법대로 따라하도록 하자.

 가장 먼저 해야할 것이 yum repository를 설정하는 것이다. 아래 명령으로 /etc/yum.repos.d/ 디렉토리에 MariaDB.repo 라는 파일을 만들고 그 아래 내용을 넣도록 하자.

$ sudo vi /etc/yum.repos.d/MariaDB.repo

# MariaDB 10.3 CentOS repository list - created 2019-01-13 00:47 UTC
# http://downloads.mariadb.org/mariadb/repositories/
[mariadb]
name = MariaDB
baseurl = http://yum.mariadb.org/10.3/centos7-amd64
gpgkey=https://yum.mariadb.org/RPM-GPG-KEY-MariaDB
gpgcheck=1

Maria DB 설치


 repository 설정을 했으면 아래 명령으로 YUM을 통해서 Maria DB를 설치할 수 있다.

$ sudo yum install MariaDB-server MariaDB-client

 설치가 진행되면서 몇 번 묻는 질문에는 모두 y를 선택하면 된다. 설치가 완료되고 나면 아무런 설정을 하지 않아도 기본 설정값으로 실행할 수 있다.

Maria DB 서버 시작


 설정 파일을 열어보면 사실 아무것도 설정하고 있는 게 없다. 하지만 그래도 기본값을 가지고 있어서 바로 시작시켜보면 정상적으로 서버가 시작된다. 아래 명령으로 시작 시켜보도록 하자.

# mariadb가 부팅 시 자동 시작되도록 한다.
$ sudo systemctl enable mariadb

# mariadb를 시작시킨다.
$ sudo systemctl start mariadb
# 또는 service 명령으로 시작시킬 수도 있다.
$ sudo service mariadb start

 정상적으로 시작되었는지 아래 명령으로 알아보도록 한다.

$ sudo service mariadb status
Redirecting to /bin/systemctl status mariadb.service
● mariadb.service - MariaDB 10.3.12 database server
   Loaded: loaded (/usr/lib/systemd/system/mariadb.service; enabled; vendor preset: disabled)
  Drop-In: /etc/systemd/system/mariadb.service.d
           └─migrated-from-my.cnf-settings.conf
   Active: active (running) since 일 2019-01-13 02:46:47 UTC; 9min ago
     Docs: man:mysqld(8)
           https://mariadb.com/kb/en/library/systemd/
  Process: 29208 ExecStartPost=/bin/sh -c systemctl unset-environment _WSREP_START_POSITION (code=exited, status=0/SUCCESS)
  Process: 29164 ExecStartPre=/bin/sh -c [ ! -e /usr/bin/galera_recovery ] && VAR= ||   VAR=`/usr/bin/galera_recovery`; [ $? -eq 0 ]   && systemctl set-environment _WSREP_START_POSITION=$VAR || exit 1 (code=exited, status=0/SUCCESS)
  Process: 29162 ExecStartPre=/bin/sh -c systemctl unset-environment _WSREP_START_POSITION (code=exited, status=0/SUCCESS)
 Main PID: 29176 (mysqld)
   Status: "Taking your SQL requests now..."
   CGroup: /system.slice/mariadb.service
           └─29176 /usr/sbin/mysqld
....
 정상적으로 시작되었으면 active(running) 상태 문구가 보일 것이다.

Maria DB 보안 설정하기


 서버가 정상적으로 시작되었으면 이제 기본적인 보안 설정을 해야한다. 보안 설정이라니 까다로울 것 같지만 사실 스크립트 하나 실행해주면 된다. 초기 mysql 시절에는 매번 수작업으로 했었던 것 같은데 이제는 제공되는 스크립트 하나를 실행해주면 되니 정말 간단해졌다. 아래 명령을 실행해보자.

$ sudo mysql_secure_installation

NOTE: RUNNING ALL PARTS OF THIS SCRIPT IS RECOMMENDED FOR ALL MariaDB
      SERVERS IN PRODUCTION USE!  PLEASE READ EACH STEP CAREFULLY!

In order to log into MariaDB to secure it, we'll need the current
password for the root user.  If you've just installed MariaDB, and
you haven't set the root password yet, the password will be blank,
so you should just press enter here.

Enter current password for root (enter for none): [Enter]
OK, successfully used password, moving on...

Setting the root password ensures that nobody can log into the MariaDB
root user without the proper authorisation.

Set root password? [Y/n] 
New password: [root 계정을 위한 비밀번호 설정]
Re-enter new password: [바로 위에서 설정한 값 다시 입력]
Password updated successfully!
Reloading privilege tables..
 ... Success!


By default, a MariaDB installation has an anonymous user, allowing anyone
to log into MariaDB without having to have a user account created for
them.  This is intended only for testing, and to make the installation
go a bit smoother.  You should remove them before moving into a
production environment.

Remove anonymous users? [Y/n] [Enter]
 ... Success!

Normally, root should only be allowed to connect from 'localhost'.  This
ensures that someone cannot guess at the root password from the network.

Disallow root login remotely? [Y/n] [Enter]
 ... Success!

By default, MariaDB comes with a database named 'test' that anyone can
access.  This is also intended only for testing, and should be removed
before moving into a production environment.

Remove test database and access to it? [Y/n] [Enter]
 - Dropping test database...
 ... Success!
 - Removing privileges on test database...
 ... Success!

Reloading the privilege tables will ensure that all changes made so far
will take effect immediately.

Reload privilege tables now? [Y/n] [Enter]
 ... Success!

Cleaning up...

All done!  If you've completed all of the above steps, your MariaDB
installation should now be secure.

Thanks for using MariaDB!

 최초 설치 후에는 root 계정에 비밀번호가 없으므로 root 비밀번호를 물으면 그냥 Enter 키를 치고 진행하면 된다. 이 후 바로 root 계정 password를 설정할거냐고 묻는데 당연히 설정하는 게 좋을 것이다. 사실 root 비밀번호를 입력하는 것 말고는 모든 질문에 그냥 Enter를 치면 Y가 기본값으로 되어있기 때문에 아무 문제없이 완료된다. 그리고 모두 Y를 선택하는 게 보안상으로도 안전한 선택이다. 그래도 혹시 원하는 바가 다를 수 있으니 한 번쯤 질문을 읽어보고 적절히 답을 하면 된다.

Maria DB 기본 설정


 이제 서버의 기본 설정 상태가 어떤지 한 번 보도록 하자. 아래 명령으로 서버에 접속해서 상태를 출력해보자.

$ mysql -u root -p
Enter password: [기 설정한 root password]
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 16
Server version: 10.3.12-MariaDB MariaDB Server

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> status
--------------
mysql  Ver 15.1 Distrib 10.3.12-MariaDB, for Linux (x86_64) using readline 5.1

Connection id:  16
Current database: 
Current user:  root@localhost
SSL:   Not in use
Current pager:  stdout
Using outfile:  ''
Using delimiter: ;
Server:   MariaDB
Server version:  10.3.12-MariaDB MariaDB Server
Protocol version: 10
Connection:  Localhost via UNIX socket
Server characterset: latin1
Db     characterset: latin1
Client characterset: utf8
Conn.  characterset: utf8
UNIX socket:  /var/lib/mysql/mysql.sock
Uptime:   25 min 58 sec

 앞에서도 얘기했지만 초기 설정 파일에는 아무 설정값도 정의되어있지 않다. 하지만 그래도 기본 설정값은 있을 것이다. Maria DB 사용자들이 흔히 설정하는 값들의 기본값은 아래와 같다.
  • datadir = /var/lib/mysql/ 
  • socket  = /var/lib/mysql/mysql.sock
  • port = 3306
  • server characterset = latin1
 datadir, socket, port 등은 사실 기본값 그대로 써도 무방하다. 하지만 요즘같은 Global 시대 latin1 characterset은 utf8로 바꿔줘야 할 것 같다. 사실 Client characterset도 latin1으로 되어있을 줄 알았는데 utf8로 되어있다는게 의외였다.

 먼저 간단하게 설정 파일의 위치와 설정값들에 대해서 얘기해 보도록 하겠다.
  • /etc/my.cnf  ==> 주 설정 파일. 파일의 내용은 /etc/my.cnf.d/ 폴더의 파일들을 include하는 것 뿐이다.
  • /etc/my.cnf.d/  ==> 이 폴더 안의 모든 .cnf 파일들이 실제 설정 파일들이며 이 파일들이 my.cnf 파일에서 include 된다.
  • /etc/my.cnf.d/server.cnf  ==> 서버를 위한 실질적인 설정 파일이다. 서버의 characterset 설정을 여기서 해주면 된다.
  • /etc/my.cnf.d/mysql-clients.cnf  ==> mysql, mysqldump 같은 client app들을 위한 설정 파일이다.
 그럼 예상한 대로 server.cnf 파일을 열어서 아래 내용을 추가해 주도록 하자.

...
[mysqld]
character-set-server = utf8
collation-server     = utf8_general_ci
...

 보면 알겠지만 mysqld 옵션 그룹에 characterset 설정만 추가해준 것이다. 이제 서버를 재시작하고 다시 mysql로 접속하여 서버의 상태를 조회해 보도록 하자.

$ sudo service mariadb restart
Redirecting to /bin/systemctl restart mariadb.service
$ mysql -u root -p
Enter password: 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 8
Server version: 10.3.12-MariaDB MariaDB Server

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> status
--------------
mysql  Ver 15.1 Distrib 10.3.12-MariaDB, for Linux (x86_64) using readline 5.1

Connection id:  8
Current database: 
Current user:  root@localhost
SSL:   Not in use
Current pager:  stdout
Using outfile:  ''
Using delimiter: ;
Server:   MariaDB
Server version:  10.3.12-MariaDB MariaDB Server
Protocol version: 10
Connection:  Localhost via UNIX socket
Server characterset: utf8
Db     characterset: utf8
Client characterset: utf8
Conn.  characterset: utf8
UNIX socket:  /var/lib/mysql/mysql.sock
Uptime:   24 sec

 이제 서버의 characterset 설정도 utf8로 바뀌었다. 혹시 client의 characterset를 바꾸고 싶다거나 추가적인 설정을 원하면 server.cnf, mysql-clients.cnf 파일을 열어서 적절한 option group에 값을 추가하면 된다. 자세한 설정 사항에 대해서는 구글링으로 해결하도록 하자.

 아주 기본적인 서버의 설치작업은 이제 끝났다. 하지만 실제 사용을 위해서는 추가적인 계정을 추가하고 방화벽 3306 포트를 열어주는 등의 작업이 필요할 것이다. 몇 가지 참고로 알아보도록 하자.

Maria DB 접속 포트 변경하기


 Maria DB의 포트 설정은 server.cnf 의 [mysqld] 옵션 그룹에 port 값을 지정해주면 된다.

...
[mysqld]
port = [포트번호]
...

 설정값을 추가하고 서버를 재시작해보면 아마도 서버 시작이 안될 것이다. CentOS 7에 기본 설정되어있는 SELinux 가 허용되지 않은 포트 access를 막기 때문이다.  물론 SELinux가 꺼진 상태라면 문제없이 시작된다.

 아래 명령으로 SELinux 설정을 수정해준다.

$ sudo semanage port -a -t mysqld_port_t -p tcp [포트번호]

 이제 서버를 다시 시작해보자. 아무 문제 없이 시작될 것이다. 그리고 방화벽에서도 새로 변경한 포트를 열어주는 걸 잊지 말자.

Maria DB 기본 명령어


 최초 설치 후에 간단한 몇 가지 작업을 하려면 매 번 명령어가 생각나지 않아서 인터넷을 검색하곤 한다. 그래서 여기에 몇 가지 자주 사용하는 명령어를 정리해보려고 한다. 아래 명령어들은 모두 mysql로 접속해서 실행할 수 있는 명령어이다.

  • Database 목록 보기
    > show databases;
  • Database 생성하기
    > create database [DB 이름];
  • Database 삭제하기
    > drop database [DB이름];
  • 사용할 Database 선택
    > use [DB이름];
  • 사용자 계정 조회
    > select user, host from mysql.user;
  • 사용자 계정 생성
    > create user 'userid'@'host' identified by 'password';
    • userid와 password는 원하는 값으로 설정.
    • host는 접속 클라이언트의 주소.('%'를 지정하면 anywhere)
  • 사용자 계정 권한 부여
    > grant all privileges on [DB이름].[Table이름] to 'userid'@'host';
    • all은 insert, select, delete, update 등으로 바꿀 수 있다. 여러 개 지정시 콤마로 분리하여 한 꺼번에 지정하면된다.
    • [DB이름].[Table이름]은 wildcard(*)를 사용할 수 있다. (*, *.*, db_name.*, ... )
    • 보안상의 이유로 일반 사용자 계정은 [DB이름].* 이상의 권한을 주지 않도록 한다.
  • 사용자 권한 확인
    >  show grants for 'userid'@'host';
  • 사용자 권한 삭제
    > revoke all on [DB이름].[Table이름] from 'userid'@'host';
  • 사용자 계정 삭제
    > drop user 'userid'@'host';
  • 권한 변경사항 적용
    > flush privileges;

 일단 이 정도로 설치 & 설정 작업을 마치도록 하겠다. 추후에 추가적으로 필요하다고 생각되는 사항이 생기면 글을 계속 업데이트할 예정이다. 많은 이에게 도움이 되길 바란다.

2018년 7월 31일 화요일

Java Enum 사용하기

 자바에서 제공하는 Enum 타입은 C++에서의 enum이 정수형 상수의 역할을 하는 것과는 달리 거의 완전한 클래스로서 새로운 타입을 정의하는 것과 같다. 덕분에 C나 C++ 같은 언어에 익숙한 개발자들이 처음 Java의 Enum을 접하게되면 약간 당황스러움을 느끼게 된다.

 그래서 이번 포스팅에서는 Java의 Enum을 이해하기 쉽도록 일반적인 Enum의 사용 코드 샘플을 보여주고 간단하게 설명 해볼까 한다.

 그럼 먼저 가장 간단한 형태의 Enum의 선언을 보자.

public enum Order {
    FIRST, SECOND, THIRD;
}

 지극히 간단한 Enum 선언 예제이다. 이렇게 선언된 Enum 타입을 어떻게 사용하는지는 아래 샘플 코드를 보면 간단하게 이해할 수 있다.

Order order = Order.SECOND;
...
// switch문
switch (order) {
    case FIRST:
        System.out.println("First !");
        break;
    case SECOND:
        System.out.println("Second !");
        break;
    case THIRD:
        System.out.println("Third !");
        break;
    default:
        System.out.println("Ooops !");
        break;
}

// if문
if (order == Order.SECOND)
    System.out.println("Second !");

 switch나 if문의 조건문에서 상수처럼 사용하는 일반적인 형태의 예제이다. 아마도 예상과 크게 다르지 않을 것이다.

 Java의 Enum은 거의 클래스와 같기 때문에 기본적으로 구현되어 제공되는 몇 가지 메소드가 있다. 실제 사용하다보면 바로 필요하게 될 것이므로 아래 예제 코드에서 사용하는 메소드의 출력 결과를 자세히 보고 숙지해 놓는게 좋겠다.

Order order = Order.SECOND;

System.out.println(order.toString()); // "SECOND"
System.out.println(order.name()); // "SECOND"
System.out.println(order.ordinal()); // 1

if (order == Order.valueOf("SECOND")) // Order.valueOf("SECOND") = Order.SECOND
    System.out.println("Same !!!");

for (Order val : Order.values()) // [FIRST, SECOND, THIRD]
    System.out.println(val);

 간단히 설명을 하자면...

  • toString()의 기본 구현은 해당 상수의 이름을 문자열로 반환한다.
  • name()도 기본 구현은 해당 상수의 이름을 문자열로 반환한다.
  • ordinal()은 해당 상수의 선언 순서에 따른 인덱스(Zero based)값을 반환한다.
  • valueOf()는 인자로 받은 이름과 같은 Enum값을 반환한다.
  • values()는 선언된 모든 Enum값을 순서대로 배열에 담아서 반환한다.
 Java의 다른 원시 타입과 함께 사용하다보면 종종 이런 메소드들이 필요하다. 하지만 매우 직관적이고 단순하므로 어려울 것은 없다. 다만 문제가 되는 것은 Enum의 기본 구현이 이런 메소드들을 제공한다는 사실을 잘 몰라서 헤메는 경우가 많으므로 반드시 숙지하기를 바란다. 이 메소드들은 overriding도 가능하므로 참고하도록 하자.

 기본적인 Enum 구현은 사실 문자열값과 함께 사용하기 편하지만 간혹 Enum의 값들이 내부적으로 정수값 같은 다른 값을 가지고 있기를 원하는 경우가 종종 있다. 이런 경우에 많이 사용하는 패턴의 샘플 코드를 보도록 하자.

public enum IntEnum {
    PENNY(1), NICKEL(5), DIME(10), QUARTER(25);

    private final int value;

    // Constructor
    IntEnum(int value) {
        this.value = value;
    }

    // Getter
    public int value() {
        return value;
    }

    public static IntEnum valueOf(Integer value) {
        if (value == null)
            throw new NullPointerException();

        if (value == 1)
            return PENNY;
        else if (value == 5)
            return NICKEL;
        else if (value == 10)
            return DIME;
        else if (value == 25)
            return QUARTER;

        throw new IllegalArgumentException();
    }
}

 처음 보여준 Order와 크게 다른 점은 내부적으로 int형 값을 가질 final 멤버 변수 'value'가 선언되어 있다는 것이다. 이렇게 final 멤버 변수를 하나 갖게 되면 Enum 상수를 정의할 때 그 값이 초기화가 되어야 하므로 반드시 생성자가 따로 추가되어야 하고 그 생성자는 반드시 private이어야 한다. 위 예제에서 PENNY, NICKEL 같은 Enum의 설계자가 정의한 값 말고 다른 값들이 외부에서 선언되어서는 안되므로 생성자는 당연히 private으로 선언되어야 할 것이다. 만약 public으로 선언을 하면 에러가 발생하므로 참고하길 바란다.

 멤버와 생성자가 추가되었으면 값을 선언할 때 인자로 정수값을 넘겨주어야 한다. 위 예제에서는 'PENNY(1)'과 같이 괄호와 정수 '1'을 값으로 넘겨서 선언하고 있음을 주목하자.

 이렇게 선언했다는 것은 당연히 다른 정수형 값들과 함께 사용하기 위함이므로 getter 메소드인 value()를 추가해주고 valueOf() 메소드를 overloading하여 문자열이 아닌 정수값으로 부터 Enum 값을 얻을 수 있도록 하였다. 반드시 필요한 건 아니지만 사용하다보면 금방 필요성을 느끼게 될 것이다.

 참고로 정수값을 보관하기 위한 'value'가 final로 선언된 이유는 실행중에 이 값이 바뀌어도 Enum값이 바뀌지는 않기 때문이다. 즉, 위 예에서 value의 final 키워드를 삭제하고 setter 메소드를 따로 추가한 후에 Runtime시에 PENNY의 value값을 10으로 바꿔줘도 PENNY는 여전히 PENNY일 뿐 DIME이 되지는 않는다. 아래 코드를 보면 이해가 쉬울 것이다.

IntEnum coin = IntEnum.PENNY;
coin.setValue(10); // coin = PENNY. not changed.
if (coin == IntEnum.DIME) // (false)
    System.out.println(coin); // not executed !!

 setValue(10)을 호출해도 PENNY인 coin 변수가 DIME으로 바뀌지는 않는다. 내부의 value값만 10으로 바뀔 뿐이다. 이렇게 value값이 바뀌게 되면 혼란만 초래할 뿐이므로 value는 final로 선언되는 게 맞으며 따라서 getter 메소드만 선언하면 되는 것이다. Java의 Enum이 거의 클래스와 같다고 하는 이유가 이런 멤버 변수와 메소드를 자유롭게 추가/변경할 수 있기 때문이지만 필요이상으로 남용하게되면 코드의 혼란만 야기할 뿐이다. 필자는 예제가 제시하는 수준의 사용이 적절하다고 생각한다. 그리고 위 예제에서는 int형을 예로 들었지만 다른 타입에 대해서 응용하는 건 이해만 제대로 하고 있다면 전혀 문제가 되진 않을 것이므로 따로 설명을 하진 않겠다.

 일반적인 상수의 사용보다 Enum을 사용하는 게 실제로 상당한 도움이 된다. Enum 사용이 익숙하지 않은 개발자들에게 간단한 샘플로서 많은 도움이 되기 바란다.