
프로젝트에 Jira를 도입했다.
이전에 칸반을 써본 적은 있지만, 제대로 활용해보진 못했다.
그런 상태에서 처음 Jira를 써봤는데, 역시 아직 익숙하지 않았다.
팀의 작업 현황이 투명하다는 점에서 좋았지만, 작업들을 일일이 관리하자니 여간 귀찮은 일이 아니었다.
특히 브랜치 따거나 머지 할 때마다, Jira에서 Task 상태 바꾸고, 이슈 연결하고 하는 게 꽤 손 가는 일이다.
그래서 Github Actions 워크플로를 이용한 Jira 자동화를 도입해보았다.
자동화 도입에 조금 번거로운 감은 있었지만, 적용 후에 시간적 비용의 감소를 확연히 체감할 수 있었다.
이번 글에서는 Github Actions로 Jira 자동화를 구현한 구조와 방식을 정리해보았다.
연동 방식은 팀과 프로젝트마다 다를 수 있습니다. 이 글은 저희 팀의 세팅에 특화된 구현으로, 각 프로젝트의 상황에 맞게 응용해야 합니다.
우리 팀에서는 작업을 에픽과 태스크로만 관리하고 있다.
그래서 "이슈 = 태스크"로 개념을 잡았고, 그리하여 Github 이슈와 Jira의 태스크를 연동하는 것을 중심으로 구조를 잡고자 하였다.
- 작업의 상위 개념인 에픽은 Jira에서 수동으로 생성한다.
- 에픽 생성 후, 깃허브에서 새로운 이슈를 등록하면 자동으로 해당 에픽의 하위 태스크로 연결된다. 이 시점에서 이슈는 Jira에서 TODO 상태다.
- 작업을 시작할 때 브랜치를 생성하면, 해당 태스크는 Jira에서 진행 중(In Progress) 상태로 전환된다.
- 이슈를 닫으면, 해당 태스크 상태는 자동으로 완료(Done)로 업데이트된다.
이러한 흐름으로 Jira를 따로 열지 않고도, 브랜치 작업만으로 상태를 자동 전환할 수 있도록 하였다.
반복되는 이슈 상태 변경 작업을 줄이고, 개발 흐름에 집중할 수 있는 환경을 만든 것이 핵심이다.
본격적으로 연동을 시작해보자.
https://id.atlassian.com/manage-profile/security/api-tokens
위 사이트에 접속하여 Jira API 토큰을 생성한다.
이때 사용하는 계정이 Jira에서 Reporter(보고자)로 등록되니, 필요에 따라 적절한 계정을 선택하면 된다.
Name은 아무렇게나 해도 상관없다.
총 3개의 키-값을 등록해야 하는데, 다음과 같다.
1. Jira API 토큰
2. Jira API 토큰을 생성할 때 사용한 계정(이메일)
3. 프로젝트 URL
프로젝트 URL은 https://플젝마다다름.atlassian.net 이러한 형식이다. 프로젝트에 접속해서 확인할 수 있다.
그리고 각 키-값들을 아래와 같이 Github Secrets에 등록한다.
New repository secret
이 글에서는 각각 JIRA_API_TOKEN JIRA_USER_EMAIL JIRA_BASE_URL라는 이름으로 저장했다.
이 글에서, 에픽은 수동으로 생성한다.
그러니 먼저 에픽을 하나 생성해보자.

예시 에픽
연동하고자 하는 것은 이슈 <-> 태스크이다.
이슈를 정해진 포맷에 맞춰 작성하면, 그 포맷에 따라 태스크가 생성되도록 할 것이다.
그러니 아래와 같은 이슈 폼을 만들어보자.

그 외 필드는 필요에 따라 추가한다.
name: '이슈 생성'
description: '이슈 생성과 동시에 Jira와 연동됩니다.'
title: '이슈 제목'
body:
- type: input
id: epicId # 상위 epic 키
attributes:
label: '💎 에픽 ID'
description: '에픽 ID를 입력해주세요'
placeholder: 'KAN-1'
validations:
required: true
- type: input
id: dueDate
attributes:
label: '🗓️ 마감일'
placeholder: '2025-05-07'
validations:
required: false
- type: textarea
id: description
attributes:
label: '📝 상세 내용'
description: '이슈에 대해서 설명해주세요'
validations:
required: false
위와 같은 issue-form.yml을 .github/ISSUE_TEMPLATE/ 경로에 위치시킨다.
그러면 이슈 생성 시 아래처럼 생성한 이슈 템플릿을 선택할 수 있게 된다.

위 폼에서 가장 중요한 부분은 상위 에픽 키에 해당하는 epicId다.
epicId를 정확히 입력해야 이슈가 해당하는 에픽의 하위 태스크로 등록될 수 있다.
이슈 폼을 결정했으니, 이제 태스크 등록을 자동화해보자.
워크플로 핵심 흐름을 요약하면:
- 이슈가 생성되면 워크플로 시작
- issue-parser 액션으로 이슈 내용을 파싱
- md2jira 액션으로 마크다운 문법을 Jira에서 렌더링할 수 있는 문법으로 변환 (Jira는 마크다운을 그대로 보여주지 못한다.)
- Jira에 태스크 생성
- Github 이슈 제목에 4번에서 생성된 태스크에 대한 키를 제목에 붙임
- Github 이슈에 생성된 태스크 링크를 코멘트로 남김
먼저 워크플로 yml 파일 전문이다. 파일 이름은 issue-to-jira.yml으로 정했다.
name: Create Jira Task
on:
issues:
types:
- opened
permissions:
issues: write
jobs:
jira-sync:
name: Create Jira Task
runs-on: ubuntu-latest
steps:
- name: Login
uses: atlassian/gajira-login@v3
env:
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
- name: Checkout main code
uses: actions/checkout@v4
with:
ref: main
- name: Issue Parser
uses: stefanbuck/github-issue-praser@v3
id: issue-parser
with:
template-path: .github/ISSUE_TEMPLATE/issue-form.yml
- name: Convert markdown to Jira Syntax
uses: peter-evans/jira2md@v1
id: md2jira
with:
input-text: |
### 깃허브 이슈 링크
- ${{ github.event.issue.html_url }}
${{ steps.issue-parser.outputs.issueparser_description }}
mode: md2jira
- name: Build fields JSON
id: issue-fields
run: |
EPIC_ID="${{ steps.issue-parser.outputs.issueparser_epicId }}"
DUE_DATE="${{ steps.issue-parser.outputs.issueparser_dueDate }}"
DESCRIPTION="${{ steps.md2jira.outputs.output-text }}"
FIELDS=$(jq -nc \
--arg parent "$EPIC_ID" \
--arg description "$DESCRIPTION" \
'{
parent: { key: $parent },
description: $description
}'
)
[ -n "$DUE_DATE" ] && FIELDS=$(echo "$FIELDS" | jq --arg duedate "$DUE_DATE" -c '. + { duedate: $duedate }')
FIELDS=$(echo "$FIELDS" | jq -c '.')
echo "fields=$FIELDS" >> $GITHUB_OUTPUT
- name: Create Task
id: create
uses: atlassian/gajira-create@v3
with:
project: KAN
issuetype: Task
summary: '${{ github.event.issue.title }}'
description: '${{ steps.md2jira.outputs.output-text }}'
fields: '${{ steps.issue-fields.outputs.fields }}'
- name: Update issue title
uses: actions-cool/issues-helper@v3
with:
actions: 'update-issue'
token: ${{ secrets.GITHUB_TOKEN }}
title: '[${{ steps.create.outputs.issue }}] ${{ github.event.issue.title }}'
- name: Add comment with Jira issue link
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: 'Jira Task Created: [${{ steps.create.outputs.issue }}](${{ secrets.JIRA_BASE_URL }}/browse/${{ steps.create.outputs.issue }})'
워크플로를 순서대로 중요한 것만 분석해보자.
워크플로 시작 조건은 이슈가 생성될 때다.
그러니 조건은 아래와 같이 작성한다.
on:
issues:
types:
- opened
이슈 제목을 수정할 것이기 때문에 이슈 수정 권한을 부여해야 한다.
permissions:
issues: write
atlassian/gajira-login@v3 액션을 이용하여 지라에 로그인한다. (https://github.com/atlassian/gajira-login)
이때 Secrets에 저장한 키들이 사용된다.
- name: Login
uses: atlassian/gajira-login@v3
env:
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
stefanbuck/github-issue-parser@v3 액션을 이용하여 이슈 내용을 파싱한다. (https://github.com/stefanbuck/github-issue-parser)
이후에 파싱한 결과를 바탕으로 태스크에 자동으로 추가 정보를 기입할 것이다.
- name: Issue Parser
uses: stefanbuck/github-issue-parser@v3
id: issue-parser
with:
template-path: .github/ISSUE_TEMPLATE/issue-form.yml

사진처럼 Jira의 본문에 이슈 링크와 이슈에서 입력했던 description을 자동으로 기입할 것이다.
Jira는 본문에 마크다운을 띄울 수 없으므로 변환해야 한다.
peter-evans/jira2md@v1을 이용한다. (https://github.com/peter-evans/jira2md)
- name: Convert markdown to Jira Syntax
uses: peter-evans/jira2md@v1
id: md2jira
with:
input-text: |
### 깃허브 이슈 링크
- ${{ github.event.issue.html_url }}
${{ steps.issue-parser.outputs.issueparser_description }}
mode: md2jira
input-text에 해당하는 내용이 마크다운이다.
마감기한과 같이 정해진 입력 포맷이 있는 입력 필드에 대해서, 요청 시 빈 값은 무시하도록 한다. 마감기한은 yyyy-MM-dd의 포맷을 지켜야 하는데, 이를 지키지 않고 빈 값으로 그대로 요청을 보낸다면 에러가 발생한다.
- name: Build fields JSON
id: issue-fields
run: |
EPIC_ID="${{ steps.issue-parser.outputs.issueparser_epicId }}"
DUE_DATE="${{ steps.issue-parser.outputs.issueparser_dueDate }}"
DESCRIPTION="${{ steps.md2jira.outputs.output-text }}"
FIELDS=$(jq -nc \
--arg parent "$EPIC_ID" \
--arg description "$DESCRIPTION" \
'{
parent: { key: $parent },
description: $description
}'
)
[ -n "$DUE_DATE" ] && FIELDS=$(echo "$FIELDS" | jq --arg duedate "$DUE_DATE" -c '. + { duedate: $duedate }')
FIELDS=$(echo "$FIELDS" | jq -c '.')
echo "fields=$FIELDS" >> $GITHUB_OUTPUT
본격적으로 Jira 태스크가 생성된다.
atlassian/gajira-create@v3을 이용한다.(https://github.com/atlassian/gajira-create)
- name: Create Task
id: create
uses: atlassian/gajira-create@v3
with:
project: KAN
issuetype: Task
summary: '${{ github.event.issue.title }}'
description: '${{ steps.md2jira.outputs.output-text }}'
fields: '${{ steps.issue-fields.outputs.fields }}'
중요한 부분은 다음과 같다.
project: 프로젝트의 키issuetype: Jira 이슈 타입(Bug, Task, Story 등). 여기선 태스크로 지정summary: 태스크 제목description: 태스크 본문. 여기에 위에서 변환한 내용이 들어간다.fields: 세부 사항을 정하는 필드. 레이블, 기한, 상위 에픽 등을 정할 수 있다. 이슈를 파싱한 값들이 여기서 사용된다.7번에 의해 생성된 태스크의 키 (ex. KAN-120)를 이슈 제목 앞에 추가한다.
태스크만 생성할 거라면 이 부분은 생략해도 상관 없으나, 작업 상황(TODO, In Progress 등)을 자동으로 옮길 수 있도록 하려면 이 방법을 사용할 수 있다.
- name: Update issue title
uses: actions-cool/issues-helper@v3
with:
actions: 'update-issue'
token: ${{ secrets.GITHUB_TOKEN }}
title: '[${{ steps.create.outputs.issue }}] ${{ github.event.issue.title }}'
이후에 이렇게 붙은 키를 바탕으로 다른 워크플로에서 태스크를 지목할 수 있도록 할 것이다.

이슈는 이렇게 된다.

제목에 태스크 키가 붙고, 코멘트가 달렸다
(이 내용부터는 글쓴이의 주관이 많이 포함된 구현으로, 적절한 방법이 아닐 수 있습니다.)
이렇게 생성된 작업은 모두 TODO 상태로 할당된다.
작업이 시작되면 진행 중으로 옮기고, 완료되면 완료로 옮기는 것도 좀 귀찮은 일이다.
이것도 자동화해보자.
우선, 진행 중인지에 대한 판단은 조금 주관적일 수 있다.
이 글에서는 브랜치 생성을 진행 중으로의 진입으로 판단하였다. (완료는 이슈 Close를 기준으로 판단)
이 자동화 로직은 규칙이 지켜져야 한다.
- 브랜치에 이슈 번호를 붙여야 한다. ex)
feat/#101-jira-test- 이슈 제목에 태스크 키를 붙여야 한다. (위에서의 자동화에 의해 자동으로 할당됨)
먼저 워크플로 전문은 다음과 같다.
name: Move Jira Issue to "In Progress"
on:
create:
branches-ignore: [ main, develop, release ]
jobs:
move-to-in-progress:
runs-on: ubuntu-latest
steps:
- name: Extract GitHub issue number from branch name
id: extract
run: |
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
if [[ "$BRANCH_NAME" =~ \#([0-9]+) ]]; then
ISSUE_NUMBER="${BASH_REMATCH[1]}"
echo "issue_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT
else
echo "No GitHub issue number found in branch name."
exit 0
fi
- name: Get GitHub issue title
id: get-issue
uses: octokit/request-action@v2.x
with:
route: GET /repos/${{ github.repository }}/issues/${{ steps.extract.outputs.issue_number }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Jira Issue Key from title
id: jira-key
run: |
TITLE="${{ fromJson(steps.get-issue.outputs.data).title }}"
if [[ "$TITLE" =~ \[([A-Z]+-[0-9]+)\] ]]; then
echo "jira_key=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
echo "No Jira key found in issue title."
exit 0
fi
- name: Jira Login
uses: atlassian/gajira-login@v3
env:
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
- name: Transition to In Progress
uses: atlassian/gajira-transition@v3
with:
issue: ${{ steps.jira-key.outputs.jira_key }}
transitionId: '21'
흐름을 따라가보자.
on:
create:
branches-ignore: [ main, develop, release ]
지정한 브랜치를 제외한 브랜치가 새로 생성되었을 때, 워크플로가 실행된다.
- name: Extract GitHub issue number from branch name
id: extract
run: |
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
if [[ "$BRANCH_NAME" =~ \#([0-9]+) ]]; then
ISSUE_NUMBER="${BASH_REMATCH[1]}"
echo "issue_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT
else
echo "No GitHub issue number found in branch name."
exit 0
fi
이슈 번호를 파싱해서 issue_number라는 환경 변수로 저장한다. 이때 ~~/#이슈번호 라는 브랜치 컨벤션이 지켜져야 워크플로가 실패하지 않는다.
- name: Get GitHub issue title
id: get-issue
uses: octokit/request-action@v2.x
with:
route: GET /repos/${{ github.repository }}/issues/${{ steps.extract.outputs.issue_number }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Jira Issue Key from title
id: jira-key
run: |
TITLE="${{ fromJson(steps.get-issue.outputs.data).title }}"
if [[ "$TITLE" =~ \[([A-Z]+-[0-9]+)\] ]]; then
echo "jira_key=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
echo "No Jira key found in issue title."
exit 0
fi
이슈 제목에서 태스크 키를 파싱하여 jira-key에 저장한다.
지라에 로그인한 후, 작업 상황을 변경한다.
- name: Jira Login
uses: atlassian/gajira-login@v3
env:
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
- name: Transition to In Progress
uses: atlassian/gajira-transition@v3
with:
issue: ${{ steps.jira-key.outputs.jira_key }}
transitionId: '21'
이때 중요한 부분이 transitionId인데, 이 값은 프로젝트마다 다를 수 있다.
transitionId는 해당 상태로 전이하기 위한 ID인데, 예를 들어 "진행 중" 상태로 전이할 때 사용하는 ID, "완료"로 전이할 때 사용하는 ID 등등이 존재할 수 있고, 이는 프로젝트마다 다를 수 있으므로 직접 확인해보아야 한다.
이를 확인하기 위해 터미널을 열고 아래 명령어를 입력한다.
curl -u API이메일:API토큰 -X GET -H "Accept: application/json" "https://플젝마다다름.atlassian.net/rest/api/3/issue/태스크키/transitions"
그러면 아래와 같은 json을 반환 받을 수 있다.
{
"expand": "transitions",
"transitions": [
{
"id": "2",
"name": "완료",
"to": {
"self": ...,
"description": "",
"iconUrl": ...,
"name": "완료",
"id": "10003",
"statusCategory": {
"self": "...,
"id": 4,
"key": "indeterminate",
"colorName": "yellow",
"name": "진행 중"
}
}
},
{
"id": "11",
"name": "해야 할 일",
"to": {
"self": ...,
"description": "",
"iconUrl": ...,
"name": "To-do",
"id": "10000",
"statusCategory": {
"self": ...,
"id": 2,
"key": "new",
"colorName": "blue-gray",
"name": "해야 할 일"
}
}
},
{
"id": "21",
"name": "진행 중",
"to": {
"self": ...,
"description": "현재 담당자가 이 업무 항목에 작업 중입니다.",
"iconUrl": ...,
"name": "진행 중",
"id": "10001",
"statusCategory": {
"self": ...,
"id": 4,
"key": "indeterminate",
"colorName": "yellow",
"name": "진행 중"
}
}
},
{
"id": "31",
"name": "완료",
"to": {
"self": ...,
"description": "",
"iconUrl": ...,
"name": "백로그",
"id": "10002",
"statusCategory": {
"self": ...,
"id": 3,
"key": "done",
"colorName": "green",
"name": "완료"
}
}
}
]
}
id(to 필드 외부에 있는 id)에 해당하는 것들이 transitionId이다.
이 워크플로 파일을 생성하고, 브랜치를 새로 생성하면 워크플로가 시작된다.

워크플로는 빠르게 완료되며, Jira에서 잠시 후 확인하면 태스크가 진행 중으로 변경되어 있음을 확인할 수 있다.

단 이 워크플로는 리모트 환경에서 브랜치를 생성했을 경우에만 실행된다. 즉, 로컬에서 새로 생성한 브랜치를 푸시한다면 작업 상황 전환은 수동으로 해야 한다.
방법을 찾아보려 했으나 한계가 있는 듯 하다...
위와 비슷한 방식으로, 이슈가 Close 되었을 때 작업 상황을 완료로 바꾸는 워크플로를 아래와 같이 만들 수 있다.
name: Close Jira Task when Issue is closed
on:
issues:
types: [closed]
permissions:
issues: read
jobs:
close-jira-task:
runs-on: ubuntu-latest
steps:
- name: Extract Jira Issue Key from title
id: extract
run: |
TITLE="${{ github.event.issue.title }}"
if [[ "$TITLE" =~ \[([A-Z]+-[0-9]+)\] ]]; then
echo "jira_key=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
echo "No Jira key found"
exit 0
fi
- name: Jira Login
uses: atlassian/gajira-login@v3
env:
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
- name: Transition Jira Issue to Done
uses: atlassian/gajira-transition@v3
with:
issue: ${{ steps.extract.outputs.jira_key }}
transitionId: '2'
역시 이슈 제목에 붙은 태스크 키를 바탕으로 작업을 수행한다.
이렇게 연동된 Jira 태스크에는 약간의 한계가 있다.
담당자 할당을 자동화하기 어렵다는 점이다.
기한이나 Label과 같은 것들은 정해진 필드 포맷만 지키면, 값을 지정하는 데에 큰 제한이 없다.
그러나 담당자는 다르다. 담당자는 Jira 프로젝트에 등록된 사용자만 할당될 수 있다.
그리고 Jira는 이를 위한 별도의 Github와의 계정 연동 기능을 제공하지 않는다.

필자는 조금 더 응용해서 Issue Label + Jira Automation 조합으로 연동(처럼 보이게)하였으나, 적절한 방법이라는 확신이 들진 않는다.
완전한 자동화를 위해서는 일정 수준의 수동 개입이 불가피하다.
자동화의 편리함을 추구하면서도, 그 한계를 명확히 인지하고 구조를 설계하는 것이 중요한 듯하다.
자동화 하나 적용해보자니 신경쓸 게 생각보다 많았다.
아직 yml 문법도 익숙하지 않고, Jira와 같은 툴들도 많이 안 써봐서, 다루는 데 어려움이 있었다.
기술 사용의 폭을 조금 더 넓혀야 할 필요성을 느꼈다.