안녕하세요. 저는 오픈소스 컨설팅 solution-dev-1팀에서 커스텀 개발을 하고 있는 김대현 프로입니다.
이번에 다룰 내용은 이전 포스트 아틀라시안 Plugin(DC 버전) 개발 가이드 (1부)끝에서 말씀드린 Plugin샘플을 만들어볼 건데요 관리자 페이지에 게시판 목록, 글 작성, 상세조회 3개 페이지로 이루어진 간단한 게시판을 만들면서 전체적인 개발 방법을 살펴보려고 합니다. 저는 Mac OS, IntelliJ IDEA 기준으로 예제를 작성했으니 참고하시기 바랍니다.

블로그 내용은 JAVA 개발을 처음 접하는 분들이 아닌 개발에 대한 기본적인 지식이 있다고 가정하고 일반적인 개발과 다른 점 위주로 진행될 예정입니다. Maven dependency를 추가하거나 일반적인 개발에 대한 설명은 생략하도록 하겠습니다.

1. DB 테이블 생성

테이블 생성은 DDL이 아닌 Entity 객체를 상속받아 생성하는 구조인데요 저는 제목, 내용, 조회 수, 생성 사용자명, 생성 날짜 5개 칼럼으로 된 간단한 테이블을 생성해 보도록 하겠습니다.

@Table("NOTICE_BOARD")
public interface NoticeBoardEntity extends Entity {
@NotNull
String getTitle();
void setTitle(String title);

@StringLength(StringLength.UNLIMITED)
String getContent();
void setContent(String content);

int getHits();
void setHits(int hits);

String getCreateUserName();
void setCreateUserName(String createUserName);

Date getCreateDate();
void setCreateDate(Date createDate);
}

어노테이션을 활용하여 index, not null, length 등 다양한 옵션을 줄 수 있습니다.
더 자세한 내용을 아래 링크를 활용해서 확인하시기 바랍니다.
https://developer.atlassian.com/server/framework/atlassian-sdk/the-active-objects-library/

Entity를 만들었으면 atlassian-plugin.xml 에 자원을 추가해 줘야 하는데요 기본적으로 자바 클래스를 생성 후 xml에 추가하여 사용하는 방식을 사용합니다.

<ao key="ao-module">
<description>The module configuring the Active Objects service used by this plugin</description>
<entity>kr.osci.sample.entity.NoticeBoardEntity</entity>
</ao>

테이블 생성 확인은 사이트에 접속하여 상단에 DbConsole 화면에 들어가 확인하면 됩니다.

저에 경우는 AO_080D11 이란 prefix가 붙은 상태로 @Table(“NOTICE_BOARD”) 에 지정된 명으로 테이블이 생성됩니다.(prefix는 환경마다 다릅니다.)

2. Service & REST API 생성

위에서 생성된 테이블에 CRUD를 해줄 서비스를 만들어 줍니다. 일반적인 서비스단 개발처럼 인터페이스를 만들어 구현해 주는 방식으로 생성해 보도록 하겠습니다.

@Getter
@Setter
public class NoticeBoardDTO {

    private int ID;

    private String title;

    private String content;

    private int hits;

    private String createUserName;

    private Date createDate;

}

DTO는 lombok사용했습니다.

public interface NoticeBoardService {

public void insertNoticeBoard(NoticeBoardDTO boardDTO);

public void updateNoticeBoard(NoticeBoardDTO boardDTO);

public void deleteNoticeBoard(Integer boardID);

public NoticeBoardDTO getNoticeBoard(int boarID)throws Exception;

public List<NoticeBoardDTO> getNoticeBoardList()throws Exception;

}
@Scanned
@Named
public class NoticeBoardServiceImpl implements NoticeBoardService {

    @JiraImport
    private final ActiveObjects activeObjects;

    @JiraImport
    private final I18nResolver i18n;

    public NoticeBoardServiceImpl(ActiveObjects activeObjects, I18nResolver i18n) {
        this.activeObjects = activeObjects;
        this.i18n = i18n;
    }

    @Override
    public void insertNoticeBoard(NoticeBoardDTO boardDTO){

        ApplicationUser lognUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser();

        NoticeBoardEntity boardEntity = activeObjects.create(NoticeBoardEntity.class
                ,new DBParam("TITLE", boardDTO.getTitle())
                ,new DBParam("CONTENT", boardDTO.getContent())
                ,new DBParam("CREATE_USER_NAME", lognUser.getName())
                ,new DBParam("CREATE_DATE", Calendar.getInstance().getTime())
        );
        
    }

    @Override
    public void updateNoticeBoard(NoticeBoardDTO boardDTO){
        NoticeBoardEntity boardEntity = activeObjects.get(NoticeBoardEntity.class,boardDTO.getID());
        boardEntity.setTitle(boardDTO.getTitle());
        boardEntity.setContent(boardDTO.getContent());
        boardEntity.save();
    }

    @Override
    public void deleteNoticeBoard(Integer boardID){
        NoticeBoardEntity boardEntity = activeObjects.get(NoticeBoardEntity.class,boardID);
        activeObjects.delete(boardEntity);
    }

    @Override
    public NoticeBoardDTO getNoticeBoard(int boarID)throws Exception{
        NoticeBoardEntity boardEntity = activeObjects.get(NoticeBoardEntity.class,boarID);

        //조회시마다 조회수 1증가
        boardEntity.setHits(boardEntity.getHits()+1);
        boardEntity.save();

        NoticeBoardDTO boardDTO = new NoticeBoardDTO();
        BeanUtils.copyProperties(boardDTO, boardEntity);

        return boardDTO;
    }

    @Override
    public List<NoticeBoardDTO> getNoticeBoardList()throws Exception{

        NoticeBoardEntity[] boardEntities = activeObjects.find(NoticeBoardEntity.class, Query.select().order("CREATE_DATE DESC"));

        List<NoticeBoardDTO> boardList = new ArrayList<NoticeBoardDTO>();

        for( NoticeBoardEntity entity : boardEntities){
            NoticeBoardDTO noticeBoard = new NoticeBoardDTO();
            BeanUtils.copyProperties(noticeBoard, entity);
            boardList.add(noticeBoard);
        }

        return boardList;
    }

}

서비스단은 일반적인 개발과 크게 다른 점은 없고 테이블 I18nResolver 객체를 통해서 메시지 국제화 처리를 할 수 있다는 점과 CRUD가 ActiveObjects를 통해서 처리된다는 점인데요 특히 SELECT 문이 어렵게 느껴질 텐데요 아래 자세한 내용은 링크를 참고하시기 바랍니다.
https://developer.atlassian.com/server/framework/atlassian-sdk/finding-entities/
메시지 국제화 처리는 화면단 설명할 때 다시 한번 자세히 설명해 드리도록 하겠습니다.

서비스단이 완성되면 요청을 받아줄 REST API 만들어보도록 하겠습니다.

@Path("/noticeBoard")
@Consumes({MediaType.APPLICATION_JSON})
@Produces({MediaType.APPLICATION_JSON})
public class NoticeBoardResource {

private Gson gson = new GsonBuilder().setPrettyPrinting().create();

private final NoticeBoardService noticeBoardService;
@JiraImport
private final I18nResolver i18n;

public NoticeBoardResource(NoticeBoardService noticeBoardService, I18nResolver i18n) {
this.noticeBoardService = noticeBoardService;
this.i18n = i18n;
}

@GET
public Response getNoticeBoardList() {
List<NoticeBoardDTO> boardList = new ArrayList<NoticeBoardDTO>();
try {
boardList = noticeBoardService.getNoticeBoardList();
}catch (Exception e){
return Response.serverError().build();
}
return Response.ok(gson.toJson(boardList)).build();
}

@GET
@Path("{id}")
public Response getNoticeBoard(@PathParam("id")final Integer boardID) {
NoticeBoardDTO boardDTO = null;
try {
boardDTO = noticeBoardService.getNoticeBoard(boardID);
}catch (Exception e){
return Response.serverError().build();
}
return Response.ok(gson.toJson(boardDTO)).build();
}

@POST
public Response insertNoticeBoard(NoticeBoardDTO boardDTO) {
noticeBoardService.insertNoticeBoard(boardDTO);
return Response.ok().build();
}

@PUT
public Response updateNoticeBoard(NoticeBoardDTO boardDTO) {
noticeBoardService.updateNoticeBoard(boardDTO);
return Response.ok().build();
}

@DELETE
@Path("{id}")
public Response deleteNoticeBoard(@PathParam("id") final Integer boardID) {
noticeBoardService.deleteNoticeBoard(boardID);
return Response.ok().build();
}

REST API를 처리하려면 자원으로 등록을 해줘야 하는데요 atlassian-plugin.xml에 추가해 줍니다

<rest key="sampleRestApi" path="/sample" version="1.0">
<description>sample rest api services.</description>
</rest>

등록이 완료되면 xml에 등록된 path, Java 단에 등록된 path를 기준으로 REST API URL 이 생성됩니다. http://localhost:2990/jira/rest/sample/1.0/noticeBoard

소스를 수정하고 서버에 반영하는 작업은 Maven package 명령어를 통해서 반영하시면 빠르게 작업하실 수 있습니다.

3. 화면 & 메뉴 추가

Plguin 개발을 하면서 가장 어려움을 겪었던 곳이 화면단 이였습니다. 아틀라시안 제품에 종속적이다 보니 제약도 많고 일반적인 개발과 다른 점도 많았습니다.

아틀라시안 Plugin화면은 기본적으로 Velocity template engine, jQuery로 구성되어 있습니다.
먼저 게시판 목록 화면을 만들어주도록 하겠습니다.

<html>
<head>
    <title>Notice Board List</title>
    <meta name="decorator" content="atl.admin">
</head>
<body>

<br>
<button class="aui-button" id="createNoticeBoard">$i18n.getText("button.create.label")</button>
<br>

<table id="notice-board-table" class="aui aui-table-sortable">
    <thead>
    <tr>
        <th>$i18n.getText("table.header.title")</th>
        <th>$i18n.getText("table.header.content")</th>
        <th>$i18n.getText("table.header.createUserName")</th>
        <th>$i18n.getText("table.header.createDate")</th>
    <tr>
    </thead>
    <tbody></tbody>
</table>

</body>
</html>

일반적인 HTML 코드로 구성된 파일이고요 주의 깊게 보셔야 할 항목들 위주로 설명해 드리도록 하겠습니다.

1)decorator

<meta name="decorator" content="atl.admin">

아틀라시안 제품에 종속적이다 보니 해당 페이지가 출력될 위치에 따라서 화면구성이 달라지게 됩니다. 위 페이지 같은경우는 관리자 페이지에 출력될 페이지라서 atl.admin이라는 decorator를 지정해 준 것입니다. 해당 값을 지정해 주면 아래와 같이 상단, 하단, 사이드 메뉴가 있는 레이아웃으로 출력됩니다.


상황에 따라서 출력될 페이지에 위치가 변경될 텐데요 그것에 맞춰 decorator값을 변경해 주시면 됩니다.
입력 가능한 값과 설명은 아래 링크를 통해서 확인 가능합니다.
https://developer.atlassian.com/server/framework/atlassian-sdk/using-standard-page-decorators/

2)메시지 국제화

Plugin에서는 i18n이라는 객체를 통해서 메시지 국제화를 지원하고 있는데요 기본적으로 생성되어 있는 sample.properties를 기준으로 sample_ko.properties 이런 식으로 언어에 따른 파일을 생성해서 메시지를 등록해 주시면 되고요 사용하는 곳에 따라 설정 방법 및 사용방법이 다르기 때문에 자세히 살펴보도록 하겠습니다.

  • volocity화면
$i18n.getText("button.create.label")

화면에서는 별도의 설정 없이 i18n이라는 객체를 사용해 메시지를 출력해 주면 됩니다.

  • javascript파일

web resource에 아래 내용을 추가해 주시고 AJS객체를 통해서 사용해 주시면 됩니다.

<transformation extension="js">
<transformer key="jsI18n"/>
</transformation>
AJS.I18n.getText("message.done");
  • xml파일

label key에 메시지 키값을 넣어주시면 됩니다.

<web-item key="noticeBoard" name="notice board" section="admin_plugins_menu/adminPluginsMenuSection" >
<label key="menu.admin.section.noticeBoard"/>
<link>/plugins/servlet/noticeBoardList</link>
</web-item>

3)Atlassian AUI

아틀라시안에서는 화면에서 사용하는 되는 컴포넌트를 제공해주고 있는데요 제공되는 컴포넌트를 사용함으로써 기존 화면과 통일성을 가지게 되고 쉽게 개발 가능하게 됩니다. 샘플에서는 버튼과 정렬 기능이 있는 테이블을 사용했습니다. 더많은 컴포넌트에 대한 정보는 아래 링크를 확인 해보시면 됩니다.
https://aui.atlassian.com/

4)Web Resource 

일반적으로 화면에서 사용되는 javascript파일, css파일은 따로 파일로 생성해서 페이지 상단에서 include를 통해서 참조하게 되는데요 Plugin에서는 web resource라는 것을 활용해서 incldue 하고 있습니다.
atlassian-plugin.xml 파일에 web resource를 생성해두고 이를 화면과 연결시켜서 해당 자원을 참조하게 됩니다.

<web-resource key="notice-board-list-resources" name="Notice board list Web Resources">
<dependency>com.atlassian.auiplugin:ajs</dependency>
<dependency>com.atlassian.auiplugin:aui-table-sortable</dependency>

<resource type="download" name="sample.css" location="/css/sample.css"/>
<resource type="download" name="noticeBoardList.js" location="/js/noticeBoardList.js"/>
<resource type="download" name="images/" location="/images"/>

<transformation extension="js">
<transformer key="jsI18n"/>
</transformation>

<context>sample</context>
</web-resource>


게시판 목록에서 사용될 css, js 선언하고요 js에서 메시지 국제화가 필요한 경우 jsI18n도 선언해 주면 됩니다. 화면과 연결시키는 방법에는 2가지가 있는데요 특정 화면과 연결시킬 경우 servlet이나 화면 상단에서 아래 와 같이 선언해서 사용하면 됩니다.

servlet에서 특정 화면과 연결 시 pageBuilderService 객체를 활용하고요

pageBuilderService.assembler().resources().requireWebResource("kr.osci.sample.sample:notice-board-list-resources");

화면에서 정의할 경우는 webResourceManager 객체를 활용하시면 됩니다.

$webResourceManager.requireResource("kr.osci.sample.sample:notice-board-list-resources")

이렇게 특정 페이지에 연결시켜주셔도 되고요 관리자 페이지 전체에 적용하고 싶은 경우는 context 값에 decorator에서 사용했던 값을 그대로 활용하면 됩니다.

<context>atl.admin</context>

화면에 대해서 같이 살펴보았는데요 화면이 완성했으면 요청을 처리할 일반적인 서블릿을 하나 만들어주고 TemplateRenderer를 통해서 view 화면과 연결시켜 줍니다.위에서 설명한 pageBuilderService를 활용해서 web resource를 include 해주고 있습니다.

@Scanned
public class NoticeBoardListServlet extends HttpServlet {

    private static final Logger log = LoggerFactory.getLogger(NoticeBoardListServlet.class);

    @JiraImport
    private final TemplateRenderer renderer;

    @JiraImport
    private PageBuilderService pageBuilderService;

    public NoticeBoardListServlet(TemplateRenderer renderer, PageBuilderService pageBuilderService) {
        this.renderer = renderer;
        this.pageBuilderService = pageBuilderService;
    }

    @Override
    public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        pageBuilderService.assembler().resources().requireWebResource("kr.osci.sample.sample:notice-board-list-resources");
        renderer.render("templates/noticeBoardList.vm", resp.getWriter());
    }
}

serlvet 생성 후 atlassian-plugin.xml 에 아래처럼 등록을 해주시면 됩니다.

<servlet key="noticeBoardListServlet" class="kr.osci.sample.servlet.NoticeBoardListServlet">
<url-pattern>/noticeBoardList</url-pattern>
</servlet>

이제 마지막으로 메뉴와 serlvet을 연결해 보겠습니다.
web-section, web-item 2가지를 통해서 메뉴와 연결시켜볼 텐데요 web-section이라는 것은 메뉴를 생성할 위치를 지정하시는것이고 web-item은 그 section에 들어가 항목들을 정의하는 것입니다.샘플 같은 경우는 위에서 설명해 드린 데로 관리자화면에 추가하였습니다.

<web-section key="adminPluginsMenuSection" name="admin plugins menu section" location="admin_plugins_menu" >
<label key="menu.admin.section"/>
</web-section>

<web-item key="noticeBoard" name="notice board" section="admin_plugins_menu/adminPluginsMenuSection" >
<label key="menu.admin.section.noticeBoard"/>
<link>/plugins/servlet/noticeBoardList</link>
</web-item>

메뉴 명도 메시지 국제화가 필요하기 때문에 label key를 통해서 키값을 지정해 주시면 됩니다.
web-section 지정 가능한 구역 목록은 아래 링크를 통해서 확인해 보시기 바랍니다.
https://developer.atlassian.com/server/jira/platform/administration-ui-locations/

주의할 점은 모든 페이지가 메뉴와 연결되지 않는다는 점인데요 메뉴와 연결되지 않은 페이지로 이동 시 section이 지정되어 있지 않기 때문에 사이드 메뉴가 없이지는 현상이 발생하게 됩니다. 예를 들어 게시판 목록 같은 경우는 메뉴와 연결되어 있지만 게시판 작성, 조회 같은 경우는 메뉴에 연결을 안 시킬 텐데요 그럴 경우 페이지 상단에 어떤 section에 연결된 페이지 인지를 지정해 주어야 합니다.

<meta name="admin.active.section" content="admin_plugins_menu/adminPluginsMenuSection"/>

게시판 목록을 완성하면 게시판 글 작성, 게시판 글 조회 등도 목록을 기준으로 만들어줍니다. 모든 작업이 완료되면 프로젝트를 실행시켜 로그인 후에 상단에 설정 > 앱 관리로 들어가 보시면 아래처럼 샘플 구역에 게시판 메뉴가 표시된 걸 확인하실 수 있습니다.

블로그에 모든 소스를 설명하기에는 너무 길어져 글 작성, 글 조회 부분은 생략하고 생략한 부분 때문에 프로젝트를 실행까지는 힘들 거라 예상되어 실행 가능한 샘플 코드를 첨부하니 참고하시기 바랍니다.
글 작성 시에 값 유효성 검사, 성공 메시지, velocity 문법, REST API 호출등 참고할 만한 내용이 있으니 소스를 다운로드해 확인해 보시기 바랍니다.

https://kdh6473@bitbucket.org/kdh6473/sampleproject.git

마무리

Plugin 개발에 대해서 전체적으로 같이 살펴봤는데요 제품에 종속적인 개발이다 보니 제약도 많고 일반적인 개발과 다른 점 때문에 처음 개발하는 분들은 어려움을 겪을 거라고 예상되는데요 특히 DC 버전 개발 가이드는 오랫동안 업데이트가 이루어지지 않고 가이드도 부족하기 때문에 많은 부분을 직접 찾아서 해결해야 했습니다. 저 같은 경우는 개발자 커뮤니티를 통해서 많은 것을 배웠는데요 이 글을 보시는 분들도 개발하다 막히는 부분이 생기면 커뮤니티를 활용해 보시기를 추천해 드립니다. 이 샘플이 Plugin APP 개발하시는 데 도움이 되었으면 좋겠습니다. 부족한 글 읽어주셔서 감사합니다. 개발 중 막히는 부분이나 궁금하신 사항은 댓글로 남겨주세요.
감사합니다.

https://community.developer.atlassian.com/

개발, 디자인 패턴, 아키텍처에 관심이 많은 꿈 꾸는 개발자입니다.

Leave a Reply

Your email address will not be published. Required fields are marked *