현재 운영중인 시스템이 메이븐1 기반이지만 메이븐2.x 로 버전업을 효율적으로 하기 위해 메이븐 플러그인을 구현하여 적용하는 방법에 대해서 알아보겠습니다.

1. 메이븐 버전업이 필요한 배경

얼마전 제가 참여했던 프로젝트의 사이트(이하 S사)는 코어시스템을 비롯하여 관련된 많은 시스템들이 메이븐1 기반으로 구현및 운영되고 있었습니다. 하지만 메이븐1.x 버전은 2007년을 마지막으로 더이상 릴리즈 되지 않고, 2013년 아파치 재단에서는 공식적으로 지원중단을 선언했습니다.
이에 S사에서도 신규 프로젝트에 대해서는 메이븐2를 사용하기를 권고했습니다.

2. 메이븐1과 2의 차이점

메이븐1에서 메이븐2로 버전업을 하기 위해서 먼저 두 버전의 차이점을 알아보겠습니다.

(1) Transitive Dependency

[그림1. Maven Transitive Dependency]

출처 : Sonatype (http://www.sonatype.com)

먄약 현재 project-a에서 위와 같은 구조를 갖는 컴포넌트를 dependency에 추가하려고 한다면, 메이븐1의 경우는 project-b, project-c, project-d, project-e를 모두 명시해 주어야 합니다. 하지만 Transitive Dependency가 지원되는 메이븐2의경우는 project-b, project-c만 명시해 주면 각 b, c가 참조하는 d, e는 자동적으로 추가되게 됩니다.

project-a의 입장에서 바라볼 때 직접적으로 참조하지 않지만 project-b, project-c에 의해서 간접적으로 참조하게 되는 project-d, project-e를 Transitive Dependency라고 합니다.
메이븐1은 이를 지원하지 않기 때문에 3rd party가 의존관계를 갖는 모든 라이브러리들을 직접 명시해 주어야 하는 불편함이 있습니다.

(2) Repository 구조

[그림2. 메이븐1 레파지토리 구조]

메이븐1의 레파지토리 구조는위와 같이 REPO_HOME/groupId/jars/artifactId-version.jar 경로에 저장됩니다. 3rd party와 같은 라이브러리의경우 jars 경로에 저장이 되지고 EJB모듈의 경우 ejbs 경로에 저장이 됩니다.

예를 들면 고객 EJB 컴포넌트의 경우 REPO_HOME/groupId/ejbs/Customer.jar 경로에 저장됩니다.

메이븐2의 레파지토리 구조는 위와 같이 REPO_HOME/groupId/artifactId/version/artifactId-version.jar 경로에 저장됩니다. 그리고 메이븐 1과는 달리 EJB와 라이브러리의 구분없이 위와같은 경로에 저장됩니다.

3. 두 가지 구조의 레파지토리를 모두 사용

앞에서 알아봤듯이 메이븐1과 2는 서로 레파지토리 구조가 다른 문제가 존재합니다. (물론 Transitive Dependency 차이로 인해 메이븐2의 POM을 구성하는데 차이가 있기는 하지만 이 글에서는 레파지토리 구조차이에 대한 이슈만 다루도록 하겠습니다.)
만약 계약 컴포넌트와 고객 컴포넌트가 존재한다고 가정했을 때, 계약 컴포넌트는 메이븐1구조 그대로 유지가 되고 고객 컴포넌트가 메이븐2로 버전업이 된다고 가정을 하겠습니다. 앞으로 고객 컴포넌트가 변경이 발생한다면 이 변경되는 내용은 메이븐2구조에만 배포가 될 것입니다. (즉, 메이븐1에 존재하는 Customer-1.0.jar파일은 특정시점 이후에 변경되지 않겠죠) 그리고 계약 컴포넌트가 아직 메이븐2로 버전업이 안된 상태라면 고객 컴포넌트를 참조하는데 최신버전이 아니라서 문제가 발생하게 됩니다.

현재 운영중인 대부분의 시스템을 하루아침에 메이븐2구조로 버전업을 하기에는 어려움이 있습니다. 이를 해결하기 위해서 기존 시스템은 메이븐1구조로 운영을 하되, 신규시스템 및 프로젝트에서 기능고도화를 하는 시스템에 한해서는 메이븐2 및 메이븐1구조에 모두 배포하는 방법을 사용하여 일정기간동안은 두 가지 구조의 레파지토리를 모두 사용하는 방법을 사용했습니다.

4. 메이븐 플러그인 구현

메이븐 빌드시 빌드 결과물을 메이븐1과 2의 레파지토리에 모두 배포하도록 하기 위해서 별도의 플러그인을 구현해 보도록 하겠습니다. 순서는 다음과 같습니다.

(1) 메이븐 프로젝트 생성
(2) 클래스 생성
(3) 파라미터 및 골(goal)설정
(4) execute() 구현
(5) 플러그인 빌드 (JAR파일 생성)
(6) 플러그인 사용

(1) 메이븐 프로젝트 생성

이클립스에서 메이븐 플러그인(Maven Integration for Eclipse)를 이용해서 프로젝트를 생성하겠습니다. [Maven Project]선택 후 Archetype은 아래 그림과 같이 maven-archetype-plugin을 선택합니다. maven-archetype-plugin을 선택하면 메이븐 플러그인은 프로젝트를 위한 dependency 정보가 자동으로 생성이 됩니다.

[그림4. 프로젝트 생성]
[그림5. archetype 선택]

프로젝트 생성 후 POM 파일을 간단히 살펴보도록 하겠습니다.

<packaging>은 maven-plugin으로 설정된것을 확인할 수 있습니다. maven-plugin 타입으로 생성된 jar파일은 다른 프로젝트에서 플러그인으로 추가하여 사용 가능합니다.

<dependencies>에는 앞으로 플러그인을 구현하기 위해 필요한 라이브러리들이 기본적으로 추가되어 있습니다.

[그림6. POM.XML]

(2) 클래스 생성

이제는 실제로 플러그인 실행시 동작할 클래스를 생성하도록 하겠습니다. 플러그인 클래스는 기본적으로 AbstractMojo(org.apache.maven.plugin.AbstractMojo) 클래스를 상속합니다. 우리는 수퍼클래스의 execute() 메소드를 구현함으로써 우리가 원하는 결과를 얻을 수 있습니다.

  • 클래스명 : kr.nextree.plugin.CopyRepo
package kr.nextree.plugin;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;

public class CopyRepo extends AbstractMojo {

	public void execute() throws MojoExecutionException, MojoFailureException {
		// TODO Auto-generated method stub
	}
}

(3) 파라미터 및 골(goal) 설정

메이븐은 각 플러그인마다 각 플러그인이 실행되는 goal이 있습니다. 예를들면 소스코드 컴파일을 담당하는 compiler의 경우 compile이라는 이름의 goal이 있고 이 goal이 실행이 되면 소스코드를 컴파일 하게 됩니다. 이 외에도 install 플러그인의 install goal도 있습니다.
이와 같이 우리가 구현하는 플러그인도 기능 수행을 위한 goal을 설정해주어야 합니다. goal 설정은 어노테이션(@)을 이용해서 설정할 수 있습니다. 아래 소스에서와 같이 클래스 레벨에서 JAVADOC에 @goal 이라고 명시한 후 이름을 적어주면 앞으로 이 이름을 이용해서 이 플러그인이 동작하게 됩니다. (예제에서는 copyRepo라는 이름으로 지정하였습니다.)

package kr.nextree.plugin;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.SocketException;

import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;

/**
 * 빌드 결과물을 메이븐1 레파지토리에 복사하는 플러그인
 * @author jiwonpark
 * @goal copyRepo
 */
public class CopyRepo extends AbstractMojo {
   ......
   ......
   ......
}

각 플러그인별로 파라미터 없이 동작이 가능한 플러그인도 있지만 경우에 따라서는 환경변수나 실행시점의 특정 값을 파라미터로 전달받아야 하는 경우도 있습니다. 여기에서는 플러그인을 실행한 컴포넌트의 groipId, artifactId, version등의 정보를 전달받도록 하겠습니다. 메이븐2로 빌드를 할 경우 빌드 결과물(JAR 파일)을 메이븐1 레파지토리에 배포하기 위해서 필요한 정보 입니다.
파라미터 역시 goal설정과 마찬가지로 어노테이션을 이용해서 설정할 수 있습니다. 파라미터를 속성으로 선언하고 JAVADOC에 @parameter라고 명시하면 이 속성들을 파라미터로 사용할수 있습니다.

/**
 * 빌드 결과물을 메이븐1 레파지토리에 복사는 플러그인
 * @author jiwonpark
 * @goal copyRepo
 */
public class CopyRepo extends AbstractMojo {

	/**
	 * @parameter
	 */
	private String groupId;

	/**
	 * @parameter
	 */
	private String artifactId;

	/**
	 * @parameter
	 */
	private String version;

	/**
	 * @parameter
	 */
	private String targetDir;

	/**
	 * @parameter
	 */
	private String targetFile;

        ......
        ......
}

그러면 플러그인에 파라미터는 어떻게 전송할까? 그 답은 POM파일에 있습니다. 플러그인을 사용할 컴포넌트의 POM파일에 플러그인 정보를 추가하고 여기에 플러그인에 전송할 파라미터도 같이 설정합니다. 아래 예제의 7~13라인이 파라미터 설정하는 부분입니다. <configuration>태그 하위에 각 파라미터들을 나열할 수 있습니다. 여기에서 <groupId>, <artifactId>와 같은 태그의 이름은 위에서 플러그인에 정의한 속성명과 일치해야 합니다.

  <build>
    <plugins>
      <plugin>
 	<groupId>kr.nextree</groupId>
  	<artifactId>maven-copy-plugin</artifactId>
  	<version>1.0.0-SNAPSHOT</version>
  	<configuration>
  	  <groupId>${project.groupId}</groupId>
  	  <artifactId>${project.artifactId}</artifactId>
  	  <version>${project.version}</version>
  	  <targetDir>${project.build.directory}</targetDir>
  	  <targetFile>${project.build.fileName}</targetFile>
  	</configuration>
  	<executions>
  	  <execution>
  	    <phase>install</phase>
  	    <goals>
  	      <goal>copyRepo</goal>
  	    </goals>
  	  </execution>
  	</executions>
      </plugin>
    </plugins>
  </build>

(4) execute() 구현

다음은 goal과 파라미터를 설정하고 이 정보를 이용하여 특정 서버에 FTP로 전송하는 기능을 구현한 소스입니다.

package kr.nextree.plugin;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.SocketException;

import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;

/**
 * 빌드 결과물을 메이븐1 레파지토리에 복사는 플러그인
 * @author jiwonpark
 * @goal copyRepo
 */
public class CopyRepo extends AbstractMojo {

  /**
   * @parameter
   */
  private String groupId;

  /**
   * @parameter
   */
  private String artifactId;

  /**
   * @parameter
   */
  private String version;

  /**
   * @parameter
   */
  private String targetDir;

  /**
   * @parameter
   */
  private String targetFile;

  private String server = "http://scompany.com:8088";  //서버 IP
  private String username = "scompany";                //사용자 Id
  private String password = "spassword";               //패스워드
  private String defaultPath = "/maven_reoo";          // 저장할 경로

  public void execute() throws MojoExecutionException, MojoFailureException {
    String destFilePath = defaultPath + "/" + groupId + "/" + artifactId + "/" + version;
    String filePath = targetDir + "/" + targetFile;
    this.upLoad(filePath, destFilePath);
  }

  /**
   * 파일을 업로드 해준다.
   * @param filePath  파일경로+파일명
   * @param destFilePath 업로드할 FTP서버 경로
   * @return
   */
  private boolean upLoad(String filePath, String destFilePath){
    FTPClient ftpClient = new FTPClient();
    ftpClient.setControlEncoding("UTF-8");  
    try {
      ftpClient.connect(server);
      int reply = ftpClient.getReplyCode();

      if (!FTPReply.isPositiveCompletion(reply)) {
        ftpClient.disconnect();
      }
      else {
	ftpClient.setSoTimeout(10000);               
	ftpClient.login(username, password);
	ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
	ftpClient.changeWorkingDirectory(defaultPath);

        //폴더 생성
        ftpClient.makeDirectory(destFilePath);

        File put_file = new File(filePath);
        FileInputStream inputStream = new FileInputStream(put_file);
        boolean result = ftpClient.storeFile(destFilePath, inputStream);

        inputStream.close();
        ftpClient.logout();
      }            
    } catch (SocketException e) {
      e.printStackTrace();
      return false;
    } catch (IOException e) {
      e.printStackTrace();
      return false;
    }
    return true;        
  }
}

(5) 플러그인 빌드 (JAR파일 생성)

구현이 모두 완료되었으면 빌드를 하도록 하겠습니다. 플러그인도 여느 메이븐 프로젝트와 빌드 방법은 같습니다. 프로젝트에서 우클릭 > [Run As] > [Maven install]을 실행하면 빌드가 완료됩니다. 그리고 레파지토리 경로를 보면 빌드가 완성되어 JAR파일이 생성된 것을 확인할 수 있습니다. (maven-copy-plugin-1.0.0-SNAPSHOT.jar)

[그림7. 플러그인 빌드]
[그림8. 빌드결과 콘솔]
[그림9. 빌드후 레파지토리에 배포된 모습]

(6) 플러그인 사용

이제 임의로 구현한 플러그인을 다른 프로젝트에서 사용하면 됩니다. 일반적인 플러그인을 사용하는것처럼 POM파일에 사용할 플러그인의 cordinate(groupId, artifactId, version)을 명시하면 됩니다. 단, maven-copy-plugin에서 파라미터로 필요한 정보만 추가로 등록하면 됩니다. 간단하게 HelloWorld 프로젝트를 생성하여 POM파일에 플러그인을 등록하도록 하겠습니다. (프로젝트 생성과정은 생략)

<executions>
  <execution>
    <phase>install</phase>
    <goals>
      <goal>copyRepo</goal>
    </goals>
  </execution>
</executions>

메이븐의 라이프사이클은 Phase단계적으로 실행되어 마지막 Phase가 실행되면서 끝나게 됩니다. 간단히 Phase를 나열해보면 validate > compile > test > package > install > deploy 의 순으로 실행이 됩니다. 그러면 maven-copy-plugin은 HelloWorld 프로젝트가 빌드되는 과정 중 어느시점에 실행이 될까요? 그 답은 POM파일에 있습니다. POM내의 <execution>태그에는 이 플러그인을 어느 phase에 실행할지 명시할 수 있습니다. 위의 경우는 install이라고 명시했죠? 그러면 기본적인 install phase가 실행이 된 후 maven-copy-plugin이 실행이 됩니다. 그리고 <execution>태그의 하위에 <goal>태그에 명시한 goal, 즉 copyRepo goal이 실행이 됩니다. (플러그인 구현시 @goal copyRepo라고 명시한 부분입니다.)

5. 마치며

메이븐 1, 2간의 버전 문제를 해결하기 위한 방법 중 하나로 플러그인을 구현하는 방법을 소개해 드렸습니다. 메이븐은 모든 기능이 많은 플러그인들의 조합으로 이루어지므로 이 기능 외에도 많은 부분에 활용할 수 있습니다. 예를 들면 빌드 완료 후 배포서버에 자동배포한다거나, UI소스를 웹서버에 자동으로 업로드 하는 기능, 그리고 빌드 전 소스코드의 결함을 체크해보는 등의 기능으로 확장이 가능하다는 생각이 들었습니다. 비록 구현은 간단하지만 메이븐을 좀 더 입맛에 맞게 사용하기에 적절한 경험이었습니다.

참조사이트

  1. Apache Maven Project (http://maven.apache.org/)
  2. Sonatype (http://www.sonatype.com)