테크니컬 컬럼-자바

XML 기반 코드 생성기 작성과 활용

객체 지향 접근 방법의 가장 중요한 장점 중의 하나는 코드의 재사용이다. 하지만, 객체 지향 프로그래밍 언어인 자바를 사용하면서도 가끔씩은 이러한 코드의 재사용이 어려운 상황이 있다. 일정한 패턴의 코드가 반복되지만, 클래스 상속을 사용하기 곤란한 경우가 주로 이러한 경우인데, 이런 경우에는 코드 생성 방법을 사용하여 재사용을 높이는 방법을 생각해볼 수 있다.

이번 호에 다룰 코드 생성기 codegen은 필자가 프로젝트를 진행하는 도중 필요에 따라 만든 것으로 이 글에서는 XML, ZIP 압축 파일, 정규 표현식, 동적 클래스 적재, 파서 생성기 등 구현에 사용된 기능을 중심으로 살펴볼 것이다.


윤경구 yoonforh at gmail dot com

티맥스소프트 기술연구소에서 EAI 팀을 이끌고 있다. 이전에는 자바 워드 프로세스 개발, PCS HLR 시스템 개발 등에 참여했다. 국내 최초로 자바 웹 게시판인 자바 묻고 답하기 게시판을 운영했으며(1997), 입문서인 지나와 함께 하는 자바 2(1999), 본격적인 자바 개발자를 위한 두꺼운 책인 자바 2 SDK 1.4 시작 그리고 완성(2003) 등의 책을 집필했다. 프로그램 세계 특집 애플릿의 향기(1996), 마소 주니어 자바 이야기(1999) 등 여러 차례 자바 관련 기사를 기고하였다. 국내 소프트웨어 산업의 어려운 현실 속에서 오래도록 코더로 남고 싶다는 소박한 희망을 가지고 있다.


가이드

운영체제 | 자바 2 1.4판 실행 가능 환경

개발도구 | 자바 2 SDK 1.4

기본지식 | 자바 2 1.4판, XML 기본 지식


오래간만에 글을 쓰게 되어 한편으로는 부담감을, 다른 한편으로는 반가움을 느낀다. 자바 언어가 세상에 처음 모습을 나타내었던 것이 1995년이니까, 이제 햇수로 따지면 10년째에 접어들었다. 자바 언어가 소프트웨어 산업에서 이렇게 중요한 역할을 하게 될 줄은 그 누구도 예측할 수 없었을 것이다.

올해에는 자바 언어에 꽤 큰 변화를 가져다 줄 자바 2 1.5 판이 여름에 발표될 예정이다. 8년 넘게 큰 변화가 없었던 자바 언어가 이제 변화를 필요로 하는 시기가 된 것은 어쩌면 자연스러운 일일 것이다. 강력한 경쟁 언어인 C#의 등장도 이와 무관하지 않을 것이고, 여러 가지 시장 상황의 절박한 요구를 받아들인 결과일 것이다. 이러한 변화가 좀더 쉬운 자바, 좀더 다양한 자바를 만들 수 있게 되길 바란다.


코드 생성의 필요성

이번 컬럼은 자바 2 1.4 판에 기반한 코드 생성기를 설명하고자 한다. 클래스 상속을 효과적으로 사용하면 대부분의 코드 반복을 피할 수 있지만, 클래스 상속을 적용하기 어려운 반복을 가끔씩 만나게 된다. 필자가 코드 생성기를 제작하게 되었던 직접적 계기는 데이터베이스 테이블의 구성에 따라 세션 빈, 엔티티 빈 등이 서로 다르게 작성되어야 하지만, 그 패턴은 동일한 경우였다. 즉, 데이터베이스 테이블 이름과 필드 이름이 각 EJB 빈 이름과 필드, 메소드 이름을 결정하고, 그 패턴은 일정하게 반복되었다. 하지만, 클래스 상속 구조를 사용해서는 적절한 EJB 빈들을 표현할 수 없었다.

그 외에도 코드 생성은 다양한 경우에 사용된다. EJB나 RMI 같은 경우 해당 컴파일러가 인터페이스 바이트코드를 읽어들여 스텁 클래스 코드를 생성해야 한다. 물론 이 과정은 개발자가 직접 하는 것이 아니라 EJB 컴파일러나 RMI 컴파일러가 수행하므로 EJB 컴파일러나 RMI 컴파일러를 만드는 사람이 고민할 문제이다. 이외에도 UML 상태 다이어그램이나 액티비티 다이어그램에서 자바 코드를 생성하는 등의 경우에도 코드 생성이 필요하다.


코드 생성의 장점과 단점

프로그래밍은 문제 해결 과정의 연속이다. 문제에 부닥쳤을 때 해결 방법은 여러 가지가 존재한다. 그 중 하나를 선택하게 되는데, 개발자는 항상 각 방법의 장점과 단점을 두고 “왜?” 라는 의문을 가질 필요가 있다.

코드 생성을 사용하는 접근법을 선택하는 경우는 대부분 코드의 불필요한 반복을, 즉 복사하여 붙여 넣는 방식의 코딩을 사람이 아닌 기계가 하는 데 목적을 두게 된다.

코드의 불필요한 반복을 막을 수 있는 다른 접근 방법인 클래스 상속 구조의 활용 방법과 장단점을 비교해보도록 하자.


<표 1> 코드 생성 방법과 클래스 상속 방법의 비교

 

코드 생성 방법

클래스 상속 방법

수행 성능

변화 가능한 부분이 코드 생성 시에 결정되므로, 컴파일 시에는 이미 코드에 반영되어 있어 수행 성능이 더 나을 수 있다.

변화 가능한 부분을 실행 시에 XML 문서 등의 환경 설정 파일이나 다른 정보 클래스에서 가져와서 판단하는 경우가 많으므로 설계에 따라 약간의 오버헤드가 발생한다.

유지 보수

모든 코드에 공통적으로 변경 사항이 발생할 경우 다시 코드를 생성해야 하는데 이중 일부 코드가 개별적으로 수정된 경우 관리가 복잡해진다.

코드 생성과 달리 중앙 집중적인 코드 변경 및 관리가 가능하므로, 유지 보수가 편리하다.


일반적으로 클래스 상속 구조를 사용하여 코드의 재사용을 높일 수 있는 상황이라면 코드 생성을 사용한 접근법을 굳이 사용할 필요가 없을 것이다. 코드 생성을 사용하여 개발하는 것은 정상적인 접근 방법은 아니기 때문이다. 특히 코드 생성은 사용되는 부분 코드들이 완벽하다는 것을 가정하는 접근 방법이기 때문에, 개발 과정에서 문제점을 발견하고 계속해서 개선해 나가는 일반적인 개발 공정에는 적합하지 않다.

그럼에도 불구하고, 클래스 상속 구조를 사용하기 곤란한 환경에서 수없이 많은 동일한 패턴의 코드를 작성해야 하는 상황이나 특별히 코드 생성을 필요로 하는 환경을 가끔 만날 수 있다. 필자는 이런 환경에서 보편적으로 사용할 수 있는 코드 템플릿 방식의 코드 생성기를 고안해보았다.


코드 생성 프로그램 codegen의 기능

먼저 코드 생성 프로그램인 codegen의 기능을 살펴보도록 하자.

codegen의 기본적인 아이디어는 생성할 코드의 원형이 될 템플릿 코드들을 먼저 작성한 다음, 코드 내용 혹은 디렉토리 이름의 일부를 변수화시켜 사용자가 지정한 값으로 변경하도록 하는 것이다.

미리 작성된 코드들은 하나의 ZIP 압축 파일로 만들어지며, 변수 치환 및 처리 방법을 하나의 XML 문서로 작성한다. 즉, ZIP 파일 형식으로 된 템플릿 파일과 템플릿 파일의 처리 방법을 기술하는 XML 문서가 하나의 코드 생성 템플릿을 구성한다. 코드 생성 프로그램은 ZIP 파일의 압축을 푸는 과정에서, XML 문서의 지시에 따라 변수 부분을 치환하거나, 지정된 자바 클래스를 실행시켜 가공하는 일을 한다.

먼저 코드 생성 프로그램을 한번 실행시켜보도록 하자.

마소 디스켓으로 제공되는 Java_Codegen.zip 파일의 압축을 풀면 codegen 디렉토리 아래에 여러 개의 디렉토리가 존재한다.

윈도우의 명령 프롬프트에서 codegen\bin 디렉토리로 가서 다음과 같이 실행시켜보자.


C:\Works\codegen\bin> codegen.bat -d ..\samples\templates


여기에서 -d 옵션은 템플릿 파일이 위치하는 디렉토리를 나타낸다.


<그림 1> codegen 프로그램 실행 모습                 


코드 템플릿 작성

codegen을 사용하려면 먼저 원하는 코드의 원형을 작성하여 템플릿으로 등록해야 한다. 간단한 코드 템플릿을 작성해보는 것이 이해를 도울 것이다. 이 글의 목적은 codegen을 소개하는 데 있는 것이 아니라, codegen을 구현하는 데 관련된 몇 가지 자바 기술들을 살펴보는 것이므로 간단하게 설명한다.

codegen 프로그램을 실행하면 codegen은 -d 옵션으로 지정된 루트 템플릿 디렉토리의 하위 디렉토리를 검사하여 적절한 템플릿들이 들어있는지 확인한다.

루트 템플릿 디렉토리의 각 하위 디렉토리 이름은 템플릿 이름으로 간주되며, codegen은 해당 디렉토리 아래에 template.xml 파일과 템플릿 이름.zip 파일을 찾는다. 예를 들어, 루트 템플릿 디렉토리 아래에 easy라는 이름의 하위 디렉토리가 있으면, 이 디렉토리 안에 template.xml 파일과 easy.zip 파일이 있는지 검사한다.

해당하는 파일들이 존재하면 template.xml 파일의 내용을 읽어 템플릿으로 등록한 다음 화면에 보여주게 된다.

예제로 작성할 템플릿은 Hello, World를 출력하는 가장 기본적인 자바 소스 파일에 변수 처리를 추가하고, 이를 codegen이 처리할 방법을 기술하는 template.xml 파일을 작성해본다. 먼저 다음과 같이 Hello.java를 작성한다.


<리스트 1> Hello.java

/*

 * $Id$

 *

 * Copyright (c) 2004 by %%company%%.

 * All rights reserved.

 */



package %%package-name%%;


/**

 * Hello World program

 *

 * @version  $Revision$<br>

 *           created at %%timestamp%%

 * @author   Yoon Kyung Koo

 */


public class Hello {

    public static void main(String[] args) {

        System.out.println("%%message%%");

    }

}


소스 파일은 아주 평범하고 단순하지만, 몇 가지 %%로 둘러싸인 부분이 눈에 띈다. 이 부분들이 변수 처리되는 부분으로 여기에서는 company, package-name, timestamp, message 등이 있다. 변수로 등록된 부분은 codegen 툴이 사용자로부터 치환할 값들을 입력받게 된다. 현재 codegen 툴은 예외적으로 사용자로부터 입력을 받지 않는 특별한 변수인 timestamp를 지원하고 있다. 이 변수는 코드가 생성된 시간으로 치환해준다.

다음은 이 소스 코드에 등록된 변수에 대한 처리 방법을 기술하는 XML 문서인 template.xml의 내용이다.


<리스트 2> template.xml

<?xml version="1.0" encoding="utf-8"?>


<!DOCTYPE template-info PUBLIC

        "-//Yoon Kyung Koo.//DTD Template Information 1.0//EN"

        "http://www.javadom.com/dtd/template_info_1_0.dtd">


<template-info>

  <name>easy</name>

  <description>EASY Framework Template</description>

  <variables>

    <root-path-var id="root" default="true">Source Root</root-path-var>

    <global-var id="package-directory">Package Directory</global-var>

    <global-var id="package-name">Package Name</global-var>

    <global-var id="company">Company Name (e.g, YoonForH Corp.)</global-var>

    <global-var id="message">Message String</global-var>

  </variables>


  <doc-info>

    <zip-path>easy/Hello.java</zip-path>

    <description>Hello World source code</description>

    <rel-path>%%package-directory%%/Hello.java</rel-path>

    <var name="message">

      <handler type="yoonforh.codegen.handler.LowerCase"/>

    </var>

  </doc-info>

</template-info>


이 XML 문서의 문법은 codegen\src\yoonforh\codegen\dtd\ 디렉토리에 있는 template_info.dtd 파일에 정의되어 있다.

간단하게 XML 구성 요소들을 살펴보면, 루트 요소인 template-info는 첫 번째 요소인 name에서 템플릿 이름을 지정한다. description 요소는 템플릿에 대한 간단한 설명을 적는 부분이다.

variables 요소는 템플릿의 실행을 위해 사용자로부터 입력을 받아야 하는 변수들을 선언하는 부분으로 템플릿 안에 포함된 파일들을 생성할 루트 디렉토리들을 지정하는 root-path-var 요소와 문서의 문자셋을 지정하는 charset-var 요소, 그리고 다른 그 외 입력을 받아야 할 전역 변수들인 gloabl-var 요소들로 구성된다.

global-var 요소들은 사용자 입력의 내용에 따라 string, csv, empty, file, directory 등의 유형을 type 속성으로 지정할 수 있다. codegen 툴은 이 type 속성에 따라 file의 경우에는 파일 대화 상자를 여는 등, 해당 변수를 입력받는 사용자 인터페이스를 적절하게 변경한다.

doc-info 요소들은 템플릿 ZIP 파일에 포함된 각 문서를 해석하는 데 필요한 정보를 지정한다. doc-info 요소는 템플릿 ZIP 파일 내에서의 문서 위치를 표시하는 zip-path, 코드 생성 시에 위치할 상대 디렉토리인 rel-path, 루트 디렉토리인 root-path, 그리고 문서의 문자셋 정보를 나타내는 charset, 그 외 각 변수별로 부가적인 처리를 지시하는 var 요소들로 구성된다.

예제의 템플릿 ZIP 파일인 easy.zip 파일에는 easy라는 하위 디렉토리에 앞에서 작성한 Hello.java 문서가 포함되어 있다. 예제 template.xml 문서에서 주의깊게 볼 부분은 rel-path의 내용에 변수를 지정할 수 있어서 실제 해당 문서가 생성되는 상대 경로를 변수에 따라 바꿀 수 있다는 점과, var 요소에서 부가적으로 handler 요소를 지정하여 특별한 처리를 할 수 있다는 점이다.

handler 요소는 type 속성으로 자바 클래스 이름을 받는데 이 클래스는 반드시 yoonforh.codegen.handler.Handler 인터페이스를 구현한 클래스여야 한다.


작성한 템플릿의 이름은 easy로 samples\templates\easy\ 디렉토리 안에 들어 있는 template.xml 파일과 easy.zip 파일을 참조하자.

codegen을 실행한 다음 easy 템플릿을 선택하여 실제 코드를 생성해보길 바란다. 간단한 위저드 방식의 사용자 인터페이스는 각 변수별로 입력을 받는 테이블을 표시한다.

이때 주의해서 볼 부분은 message 변수인데, 이 변수에 치환할 값을 예를 들어 Hello, World라고 입력하면 실제 생성되는 코드는 소문자로 바뀐 hello, world가 된다. 이것은 변수에 부가적인 처리를 하는 핸들러인 yoonforh.codegen.handler.LowerCase 클래스가 등록되어 있어 변수 내용을 소문자로 바꾸어 주는 때문이다.

함께 포함되어 있는 예제 템플릿인 meta 템플릿의 template.xml 문서를 열어보면, 핸들러의 훨씬 복잡한 사용법을 볼 수 있을 것이다.


codegen 프로그램의 구현 테크닉

이번 컬럼의 주제는 codegen 프로그램을 잘 사용하는 데 있지 않고, 이러한 프로그램을 작성할 때 사용되는 몇 가지 재미있는 기술들을 살펴보는 것이다.


ZIP 압축 처리

codegen에서 가장 유용하게 사용하고 있는 자바의 기능 중 하나는 java.util.zip 패키지가 제공하는 ZIP 파일 처리 기능이다. yoonforh.codegen.util.zip.ZipExtractor 클래스의 extract() 메소드는 템플릿 ZIP 파일을 읽어들인 다음, 압축을 풀면서 지정된 일을 처리한다.

<리스트 3>에서 볼 수 있듯이 extract() 메소드는 ZipInputStream을 사용하여 ZIP 파일을 읽어들인 다음 각 엔트리별로 ZIP 파일에 포함된 내용을 처리하도록 구현되어 있다.


<리스트 3> ZipExtractor.java 부분 코드

    public void extract(File zipFile) throws IOException, ApplicationException {

        if (zipFile == null) {

            logger.warning("src zip file is null.");

            throw new NullPointerException("src zip file is null");

        }


        ZipInputStream zis = null;


        try {

            zis = new ZipInputStream(new BufferedInputStream(

                                         new FileInputStream(zipFile), BUFFER_SIZE));

            byte[] buffer = new byte[BUFFER_SIZE];


            while (true) {

                // ZIP 파일의 다음 엔트리를 가져온다.

                ZipEntry entry = zis.getNextEntry();

                if (entry == null) {

                    break;

                }


                String entryName = entry.getName();

                long entrySize = entry.getSize();

                boolean isDirectory = entry.isDirectory();


                // worker에게 엔트리를 저장하기 위한 출력 스트림을 열도록 요청한다.

                OutputStream output = worker.openEntry(entryName, entrySize, isDirectory);

                // worker가 스트림을 제대로 넘겨주었는지 검사

                if (output == null) {

                    worker.closeEntry(entryName, isDirectory, output);

                    continue;

                }


                int result = 0, nWrote = 0;


                try {

                    // ZIP 엔트리를 푼다

                    while ((result = zis.read(buffer, 0, BUFFER_SIZE)) > 0) {

                        output.write(buffer, 0, result);

                        nWrote += result;

                    }


                    logger.finest("wrote " + nWrote + " bytes");

                } finally {

                    output.close();

                }


                // ZIP 엔트리가 모두 풀렸으므로, worker에게 적당한 일을 하도록 요청한다.

                worker.closeEntry(entryName, isDirectory, output);

            }

        } finally {

            if (zis != null) {

                zis.close();

            }

        }

    }


실제 엔트리별로 압축을 풀 때 실행되는 것은 같은 패키지에 정의되어 있는 ZipWorker 인터페이스의 각 메소드들로 ZIP 파일의 압축을 풀어주는 ZipExtractor 클래스로부터 독립되도록 설계하였다. codegen에서 ZIP 파일의 압축을 풀 때 수행하는 모든 일들은 yoonforh.codegen.util.CodeGenZipWorker 클래스의 closeEntry() 메소드에서 일어난다.


<리스트 4> CodeGenZipWorker.java 부분 코드

    /**

     * ZIP 엔트리가 모두 풀렸을 때 호출된다. 여기에서 추출된 스트림을 처리할 수 있다.

     *

     * @param entryName ZIP 엔트리 이름

     * @param isDirectory 엔트리가 디렉토리인지 여부

     * @param output 닫긴 출력 스트림

     * @exception IOException 출력 스트림을 열 때 발생할 수 있는 IO 에러

     * @exception ApplicationException 처리 중 일어날 수 있는 에러

     */

    public void closeEntry(String entryName, boolean isDirectory, OutputStream output)

        throws IOException, ApplicationException {

        // openEntry() 메소드에서 넘겨주었던 바이트 배열 출력 스트림 객체

        ByteArrayOutputStream bout = (ByteArrayOutputStream) output;

        String zipPath = entryName.replace('\\', '/');

        String rootPath = null;

        String relPath = null;

        String charset = null;

        HashMap varInfos = null;

        TextProcessor currentProcessor = processor;


        DocInfo docInfo = (DocInfo) docInfos.get(zipPath);


        // 엔트리가 디렉토리인 경우에는 여기에서 아무 일도 하지 않는다.

        if (isDirectory) {

            return;

        }


        if (docInfo == null) {

            // docInfo가 널이면 이 엔트리는 template.xml 파일에 등록되지 않은 엔트리이다.

            // 여기에서는 기본 동작을 기본 루트 경로로 압축을 풀도록 했다.

            rootPath = defaultRootPath;

            relPath = zipPath;

        } else {

            // template.xml 파일의 doc-info 요소로부터 루트 경로 변수 정보를 가져온다.

            String var = docInfo.getRootPathVar();

            if (var != null) {

                rootPath = (String) rootPaths.get(var);

            }

            if (rootPath == null || rootPath.length() == 0) {

                rootPath = defaultRootPath;

            }


            // template.xml 파일의 doc-info 요소로부터 processor 정보를 가져온다.

            // processor는 변수에 적용되었던 핸들러와 달리 파일에

            // 부가적으로 적용되는 특별한 일을 수행한다

            String processorType = null;

            ProcessorInfo pInfo = docInfo.getProcessorInfo();

            if (pInfo != null) {

                processorType = pInfo.getType();

            }


            if (processorType != null && processorType.length() > 0) {

                // 프로세서 유형이 지정되었으면 현재 프로세서를 변경한다.

                currentProcessor = getProcessorInstance(processorType);

            }


            // 상대 경로가 지정되어 있으면 ZIP 엔트리 경로 대신 상대 경로를 사용한다.

            // 상대 경로에는 변수가 포함될 수 있다.

            relPath = docInfo.getRelPath();

            if (relPath == null || relPath.length() == 0) {

                relPath = zipPath;

            }


            var = docInfo.getCharsetVar();

            if (var != null) {

                charset = (String) charsets.get(var);

            }

        }


        // 현재 등록된 프로세서에게 파일을 생성하도록 요청한다.

        // 프로세서는 하나의 엔트리를 사용해서 여러 개의 파일을 생성할 수도 있다.

        currentProcessor.generateFile(

            (bout != null ? bout.toByteArray() : null), rootPath, relPath,

            isDirectory, charset, docInfo);

    }


변수 처리를 위한 정규 표현식

codegen 프로그램은 변수 처리를 위해 자바 2 1.4 판에 새롭게 추가되었던 java.util.regex 패키지의 정규 표현식을 사용한다.

codegen의 변수 표기 방법은 “%%” 사이에 변수 이름을 두는 것인데, 선택적으로 부가적인 대소문자 처리를 위해 변수 이름 다음에 “:”을 추가하고 내부적으로 이해하는 대소문자 처리 명령을 줄 수 있다. 예를 들어,


%%message:lower%%


와 같은 표현은 message라는 변수를 처리한 다음, 내용을 모두 소문자로 변경하라는 명령이다.

지원하는 명령에는 csu(대문자로 시작하는 대소문자 규칙), csl(소문자로 시작하는 대소문자 규칙), upper(대문자로), lower(소문자로) 등이 있다. 자주 사용되는 명령을 위해 변수에 핸들러를 일일이 지정하지 않아도 되도록 한 배려라고 생각할 수 있다.

변수 이름에는 알파벳과 숫자, 그 외에 “-”, “_”가 올 수 있으며, 첫 번째 글자는 반드시 알파벳이어야 하는 일반적인 변수 규칙을 따른다. 이 구조를 표현하는 정규표현식은 다음과 같다.


String PATTER_EXPR = "%%([\\p{Alpha}][\\p{Alnum}-_]*?)(?:\\:([\\p{Alnum}-_]*?))?%%";


정규 표현식 패턴의 적용은 yoonforh.codegen.processor.TextProcessor 클래스의 apply() 메소드에서 일어난다.


<리스트 5> TextProcessor.java 부분 코드

    /**

     * 정규표현식과 일치하는 부분들을 찾아서, 패턴을 적용한 다음 핸들러를 호출한다.

     *

     * @param contents 변수 선언부를 포함하는 내용 부분

     * @param varInfos 변수 이름과 변수 관련 정보 객체 맵

     * @return 치환된 문자열

     */

    public String apply(CharSequence contents, HashMap varInfos) {

        // Pattern 객체는 Matcher 객체와 달리 reentrant하므로 캐시해둔다.

        Matcher matcher = getPattern().matcher(contents);

        // 여기에서 50은 의미없는 숫자로 적당히 조금 더 큰 버퍼를 잡았다.

        StringBuffer buffer = new StringBuffer(contents.length() + 50);


        int count = 0;

        while (matcher.find()) {

            count++;


            // group() 혹은 group(0)은 일치된 문자열 전체를 나타낸다

            // 첫 번째 그룹은 대소문자 처리 명령을 뺀 변수 이름

            String variable = matcher.group(1);

            // 두 번째 그룹은 대소문자 처리 명령

            String caseInst = matcher.group(2);

            // value는 사용자가 입력한 변수의 치환 값이다.

            // template.xml 문서에서 var 유형을 empty로 지정하면 value가 널이 된다.

            String value = (String) vars.get(variable);


            // 대소문자 명령 처리

            if (value != null) {

                value = processCase(value, caseInst);

            }


            // 치환된 변수에 대해 등록된 핸들러를 차례로 실행한다.

            if (varInfos != null) {

                Variable varInfo = (Variable) varInfos.get(variable);

                List handlers = null;

                if (varInfo != null) {

                    handlers = varInfo.getHandlerList();

                }

                if (handlers != null) {

                    Iterator iter = handlers.iterator();

                    while (iter.hasNext()) {

                        HandlerInfo handler = (HandlerInfo) iter.next();


                        String handlerClazz = handler.getType();

                        HashMap paramMap = handler.getParamMap();

                        if (handlerClazz != null) {

                            value = invokeHandler(handlerClazz, value, paramMap);

                        }

                    }

                }

            }


            if (value != null) {

                // 치환 및 핸들러가 실행된 결과를 적용한다.

                matcher.appendReplacement(buffer, value);

            } else { // value가 null일 경우에는 변수를 그냥 쓴다.

                logger.warning("cannot find value for variable - " + variable);

                matcher.appendReplacement(buffer, variable);

            }

        } // while 문 끝


        if (count > 0) { // 변경이 하나라도 있었던 경우

            matcher.appendTail(buffer);

            return buffer.toString();

        }


        // 변경이 없을 경우 원래 내용을 반환한다.

        return contents.toString();

    }


핸들러 추가를 위한 동적 클래스 적재

codegen 프로그램의 큰 특징으로는 사용자가 직접 자바 클래스를 코딩하여 핸들러를 추가할 수 있다는 것이다. 자바의 동적인 클래스 적재 특성을 활용하는 것으로 대부분의 자바 프로그램들은 플러그인과 같은 부가 기능 등을 원래의 프로그램에 추가할 수 있도록 플러그인을 위한 별도의 클래스 적재기를 지원한다.

자바의 클래스 적재기(class loader)는 크게 다음 세 가지로 구분된다.


◆ 시스템 클래스 적재기 : 부트스트랩 클래스 적재기라고도 부르며, 자바가 기본으로 제공하는 클래스들을 적재하는 데 주로 사용된다. 부트스트랩 클래스 경로에 있는 클래스들(java 혹은 javax로 패키지 이름이 시작하는 대부분의 자바 클래스들)을 적재한다.

◆ 응용 프로그램 클래스 적재기 : 사용자가 작성한 응용 프로그램 클래스들을 적재하는 데 사용되는 클래스 적재기. codegen 프로그램의 클래스들은 거의 대부분 이 응용 프로그램 클래스 적재기에 의해 적재된다. 응용 프로그램 클래스 적재기는 java.net.URLClassLoader 클래스의 자식 클래스이며, URLClassLoader 클래스는 java.security 패키지의 SecureClassLoader의 자식 클래스이다. 즉, 응용 프로그램 클래스 적재기에 의해 적재되는 모든 클래스들은 SecureClassLoader의 기능에 의해 보안 검사를 거치게 됨을 주목하자. 시스템 클래스 적재기에 의해 적재되는 클래스들은 보안 검사를 거치지 않는다.

◆ 커스텀 클래스 적재기 : 플러그인 등의 목적으로 개발자가 직접 작성한 클래스 적재기. java.lang.ClassLoader 클래스의 자식 클래스여야 한다.


ClassLoader 클래스를 상속하는 커스텀 클래스 적재기를 작성할 때에는 대부분의 경우 findClass() 메소드를 오버라이드하여 구현하게 된다. 필요에 따라 findResource(), findLibrary() 메소드 등도 오버라이드하여 구현할 수 있다. <리스트 6>은 간단한 findClass() 메소드를 오버라이드하여 사용하는 예이다.


<리스트 6> ClassLoader의 findClass() 오버라이드 예제

    /*

     * 자바 2 버전 1.2 이후부터는 ClassLoader의 자식 클래스들은

     * loadClass(String, boolean) 대신에

     * findClass(String) 메소드를 오버라이드하여 구현할 것을 권장한다.

     * findLoadedClass(name)이 먼저 호출되어 발견되면 이 메소드는 호출되지 않는다.

     *

     * @param name 클래스 이름

     */

    protected Class findClass(String name) throws ClassNotFoundException {

        // loadClassData() 메소드는 클래스 이름에 해당하는 바이트 배열을

        // 가져오는 코드가 들어 있어야 한다.

        byte[] b = loadClassData(name);

        if (b != null) {

            return defineClass(name, b, 0, b.length);

        } else {

            return null;

        }

    }


codegen에서 핸들러 클래스들을 추가로 읽어들일 수 있기 위해 지원하는 클래스 적재기는 java.net 패키지의 URLClassLoader 클래스를 상속하여 작성하였다.

URLClassLoader 클래스는 생성자로부터 넘겨받은 URL들이 zip 파일이나 jar 파일일 경우 해당 파일에 포함된 클래스들을 인식하므로, 이 기능을 활용하면 특정한 디렉토리에 있는 모든 zip 파일이나 jar 파일을 클래스 경로에 포함하여 읽어들인 클래스 적재기를 작성하는 것이 아주 간단해진다.

핸들러 클래스를 읽어들일 수 있는 yoonforh.codegen.util.DirectoryClassLoader 클래스는 다음과 같이 아주 간단한 코드로 구현되었다. 한 가지 고려할 점은 클래스 적재기에는 부모 클래스 적재기 개념이 존재하는데, 현재의 클래스 적재기가 지정된 클래스를 읽어들이지 못할 경우, 부모 클래스 적재기에게 처리를 요청하도록 설계해야 한다는 것이다. DirectoryClassLoader의 경우, 부모 클래스 적재기를 DirectoryClassLoader 클래스를 읽어들인 클래스 적재기 즉, 응용 프로그램 클래스 적재기로 지정하게 되어 있다.


<리스트 7> DirectoryClassLoader.java 부분 코드

/**

 * 특정 디렉토리에 있는 Jar 혹은 Zip 파일들로부터 정의된 클래스를 적재하는 클래스 적재기

 * Jar나 Zip 파일이 없을 경우 해당 디렉토리를 클래스 경로로 인식한다

 */


public class DirectoryClassLoader extends URLClassLoader {

    private static HashMap loaderMap = new HashMap();


    /**

     * @param urls jar, zip 파일들의 URL 표현

     * @param parent 부모 클래스 적재기

     */

    private DirectoryClassLoader(URL[] urls, ClassLoader parent) {

        super(urls, parent);

    }


    /**

     * 지정된 디렉토리를 위한 클래스 적재기를 반환한다.

     * @param directory 적재할 jar 혹은 zip 파일들이 위치한 디렉토리

     */

    public static DirectoryClassLoader getClassLoader(File directory) throws IOException {

        String path = directory.getCanonicalPath();

        DirectoryClassLoader loader = (DirectoryClassLoader) loaderMap.get(path);

        if (loader == null) {

            if (!directory.isDirectory()) {

                throw new IOException("Given path is not a directory - " + path);

            }


            // 해당 디렉토리의 zip 혹은 jar 파일 목록을 가져온다.

            File[] files = directory.listFiles(new FilenameFilter() {

                    public boolean accept(File dir, String name) {

                        if (name == null || name.length() == 0) {

                            return false;

                        }


                        String lowered = name.toLowerCase();

                        if (lowered.endsWith(".zip")

                            || lowered.endsWith(".jar")) {

                            return true;

                        }


                        return false;

                    }

                });


            URL[] urls = null;

            if (files.length == 0) {

                urls = new URL[1];

                urls[0] = directory.toURL();

                // 디렉토리 URL은 반드시 '/'로 끝나야 한다.

                // 그렇지 않으면 URLClassLoader가 ZIP 파일로 간주한다.

            } else {

                urls = new URL[files.length];

                for (int i = 0; i < files.length; i++) {

                    urls[i] = files[i].toURL();

                }

            }


            // 클래스 적재기 인스턴스 생성

            loader = new DirectoryClassLoader(urls,

                 // DirectoryClassLoader의 부모 클래스 적재기는 응용 프로그램 클래스 적재기가 된다.

                    DirectoryClassLoader.class.getClassLoader());

            // 캐시한다.

            loaderMap.put(path, loader);

        }


        return loader;

    }


}


핸들러를 실행하는 부분은 앞에서 잠깐 언급하였던 TextProcessor 클래스에 포함되어 있다.

클래스 적재기 인스턴스를 구한 다음 Class.forName() 메소드를 호출하여 Handler 구현 클래스를 적재한 다음, Handler 인터페이스의 handle() 메소드를 호출한다.


    ClassLoader loader = DirectoryClassLoader.getClassLoader(new File(handlerDirectory));

    Handler handler = (Handler) Class.forName(clazz, true, loader).newInstance();

    String result = handler.handle(value, paramMap, vars);


codegen에서 기본으로 포함된 핸들러 클래스들은 모두 응용 프로그램 클래스 적재기에서 읽어들이게 되므로, 별도의 핸들러 디렉토리 지정이 필요없지만, 사용자가 추가하고자 할 경우에는 다음과 같이 시스템 속성을 지정해서 codegen을 실행하면 된다. 예를 들어 C:\tmp 라는 디렉토리에 myhandler.zip이라는 파일에 정의된 핸들러 클래스를 넣어둔다면 다음과 같이 실행할 수 있다.


c:\works\codegen\bin> java -Dhandler.directory=C:\tmp -jar ..\dist\codegen.jar -d ..\samples\templates



JavaCC 파서 생성기

마지막으로 codegen에 숨어있는 특이한 기능 중 하나는 오러클 SQL 문 파서이다. yoonforh.codegen.parser 패키지에 정의되어 있는 파서 클래스들은 JavaCC라는 유명한 파서 생성기 툴을 사용하여 생성한 소스 코드들이다.

그러고 보면, codegen이라는 코드 생성기의 일부 코드는 파서 코드 생성기가 생성해주는 코드를 활용하고 있는 셈이다.

JavaCC 툴은 현재 공개 소스 프로젝트로 전환되어 계속 유지되고 있으며, 다음 URL에서 다운로드할 수 있다.


http://javacc.dev.java.net


JavaCC 툴을 아주 간단하게 소개하자면, 컴파일러에서 말하는 문법에 기반하여 해당 문법을 따르는 스트림을 파싱하고 파싱된 결과에 따라 지정한 코드를 실행시키는 코드를 생성해주는 툴이다. C 언어라면 lex나 yacc 같은 툴에 해당한다고 생각할 수 있다. 컴파일러를 잘 이해하는 독자라면 JavaCC는 LL(1) 문법에 기반하여 룩어헤드를 지원하는 독특한 파서 생성기라고 생각하면 된다.

JavaCC는 문법과 각 프러덕션에 해당하는 실행 코드를 지정하는 입력 파일인 .jj 파일의 작성을 요구한다.

src/yoonforh/codegen/parser/oracle-create-table.jj 파일에는 CREATE TABLE SQL 문에 관련된 오러클 데이터베이스의 문법이 지정되어 있으며, 파싱 과정에서 각 테이블 이름과 필드 이름, 속성 등을 추출해내는 코드가 포함되어 있다.

.jj 파일은 다음과 같은 구조로 되어 있다.


<리스트 8> JavaCC 입력 파일의 구조

options {

  // 여기에는 JavaCC 옵션이 지정된다.

  // 예를 들면

  // DEBUG_PARSER = true;

}


PARSER_BEGIN(파서 클래스 이름)

  // 여기에는 사용자가 지정하는 해당 클래스의 자바 소스 코드가 들어온다.

  // 예를 들면

  // public class MyParser { ... }

PARSER_END(파서 클래스 이름)


프러덕션 문법

// 여기부터는 JavaCC가 이해할 수 있는 토큰 선언 혹은 프러덕션 선언 등 문법 사항이 지정된다.



프러덕션 문법은 몇 가지 형태를 지니는데, 예를 들어 다음과 같은 BNF 프러덕션 문법을 지정할 수 있다.


String /* 자바 반환 유형 */

 DayPrecision() /* 프러덕션 이름은 자바 메소드 이름으로 해석, 인자도 올 수 있다. */ :

{ // 첫 번째 블록은 자바 변수 선언부

    String value = null;

}

{ // 실제 프러덕션의 확장에 해당하는 부분

  value = Digit()

  { // 일치하는 프러덕션을 만날 경우 실행될 자바 코드 블록

    return value;

  }

}


JavaCC 사이트에는 잘 정리된 FAQ 문서와 기타 문서들이 연결되어 있으므로, 자세한 내용은 사이트를 참조하길 바란다.

codegen 프로그램은 이 파서를 사용하여 CREATE TABLE 문들이 포함된 SQL 문을 읽어들여, 해당 데이터베이스 테이블별로 엔티티 빈을 생성하는 기능을 지원할 수 있게 설계되어 있다. 포함된 예제 중 meta 템플릿을 참고하기 바란다.


컴파일과 빌드

codegen 프로그램에는 Ant 빌드 툴을 위한 build.xml 스크립트가 포함되어 있다.

ant dist


를 실행하면 소스 코드를 컴파일하여 dist 디렉토리에 codegen.jar 파일을 생성해줄 것이다.

그리고 documents 디렉토리에는 codegen 프로그램의 사용법이 담긴 PDF 문서가 있다. 참고하길 바란다.


새해를 맞이하며

IT 산업에 종사하는 대부분의 사람들은 어렵고 힘든 터널 속에 있음을 느낄 것이다. 2004년 새해는 이 터널을 벗어나 활력이 넘치고 도약하는 시기가 되었으면 한다. 다시 닷컴 시대와 같은 거품으로 흥청대는 것을 기대하진 않지만, 내실 있는 IT 기업들이 가치 실현할 수 있는 상황을 기대해본다.

자바를 사랑하는 독자들은 자바 2 1.5 판의 기능들을 많이 기다릴 것 같다. 언제나 새로운 것은 젊음을 설레게 하지만, 새로움이 발 딛고 있는 터전을 잘 다지지 않으면 기다림은 허망할 수 있다.

5년만에 마소 지에 글을 쓰게 되니, 감회가 새롭다. 자바 초창기에 북적였던 필자의 웹 게시판이 요즘은 아주 초라해질 정도로 다양한 개발자 커뮤니티가 국내에도 형성된 것 같아 마냥 기쁘다.

다음에는 좀더 나은 코드를 소개하게 되길 바라며 모두 건승하시길...


참고자료

1 윤경구의 자바 도메인

http://www.javadom.com

2 JavaCC - The Java Parser Generator

http://javacc.dev.java.net