๐ ๏ธ ๊ฐ๋ฐ ํ๊ฒฝ
- Kotlin : 1.9.25
- Spring boot : 3.5.6
- Github
๐จ ์์ฒญ ํ๋ฆ
์ฝ๋ ๋ฆฌ๋ทฐ๊ฐ ์งํ๋๋ ํ๋ฆ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
PR → Github webhooks → Server → Google AI Studio → Get Github Apps(Bot) token → Github PR Comment
๊ฐ์ธ Token์ ์ฌ์ฉํ์ฌ Comment๋ฅผ ๋จ๊ธธ ๊ฒฝ์ฐ, ๋ณธ์ธ ๊ณ์ ์ผ๋ก ๋ฆฌ๋ทฐ๋ฅผ ๋จ๊ธฐ๊ฒ ๋ฉ๋๋ค. ์ฌ๋ฌ ๋ช ์ด์ ์์ ํ๋ ํ๊ฒฝ์์ ์คํด์ ์ฌ์ง๊ฐ ๋ฐ์ํ ์ ์์ด, Github Apps๋ฅผ ์ฌ์ฉํด Bot์ ์์ฑํ์ฌ ํด๋น Bot์ Token์ ๋ฐํํด ๋ฆฌ๋ทฐ๋ฅผ ๋จ๊ธฐ๊ฒ ๋ฉ๋๋ค.
๐จ ์ฃผ์ ์ฌํญ
์ด ํฌ์คํ ์ EC2 ์ธ์คํด์ค๊ฐ ์์ผ๋ฉฐ, ๋ณธ์ธ์ ๋๋ฉ์ธ์ด ์๋ค๋ ๊ฒ์ ๊ฐ์ ํ๊ณ ์งํํ๋ฉฐ, Google AI Studio๋ฅผ ์ฌ์ฉํ๊ธฐ์ ๋ฌด๋ฃ ์๋น์ค ์ฌ์ฉ ์, ์ ๋ ฅํ ๋ด์ฉ๊ณผ ์์ฑ๋ ์๋ต์ Google์ ์ ํ ๋ฐ ์๋น์ค ๊ฐ์ ์ ์ํด ์ฌ์ฉ๋ ์ ์์ผ๋ ์ฃผ์ํ์๊ธธ ๋ฐ๋๋๋ค.
ํ๋ฆฌํฐ์ด์ Google AI Studio์ ๊ฒฐ์ ์ ๋ณด๋ฅผ ์ ๋ ฅํ์ง ์์ ๊ฒฝ์ฐ ๊ณผ๊ธ ์ฐ๋ ค๋ ์์ต๋๋ค.
๋ฌด์์ ๋ฐ๋ผํ๋ ๊ฒ๋ณด๋จ, ๊ธ์ ํ์ด ๋ณธ ๋ค, ๋ณธ์ธ์๊ฒ ํ์ํ ๋ด์ฉ์ธ์ง ํ์ธ ํ ๋ฐ๋ผํ์๊ธธ ์ถ์ฒ๋๋ฆฝ๋๋ค. ๋ํ, ๊ฐ๋ฐ ๊ณผ์ ๋ฐ ๋ฆฌ๋ทฐ ํ๋ฆ์ ์ ๋ฆฌํ๊ธฐ ์ํ ํฌ์คํ ์ด๋ฏ๋ก, ๋ชจ๋ ์ฝ๋๊ฐ ํฌ์คํ ์ ํฌํจ๋์ง ์์ต๋๋ค.
์ ์ฒด ์ฝ๋๊ฐ ํ์ํ ๊ฒฝ์ฐ, ๋ฆฌํฌ์งํ ๋ฆฌ๋ฅผ ์ฐธ๊ณ ํด์ฃผ์ธ์. ๋ฆฌํฉํฐ๋ง ๋ฑ์ผ๋ก ์ธํด ๋ธ๋ก๊ทธ ํฌ์คํ ๊ณผ ์ค์ ๊ฐ๋ฐ๋ฌผ์ด ์ฐจ์ด๊ฐ ์๊ธธ ์ ์๋ ์ ์ ์ํด์ฃผ์ธ์. ๊ถ๊ธํ ์ ์ด ์๋ค๋ฉด ๋๊ธ ๋๋ Issue์ ๋จ๊ฒจ์ฃผ์๋ฉด ๊ฐ์ฌํ๊ฒ ์ต๋๋ค!
์ด ํฌ์คํ ์ ๋ธ๋ก๊ทธ ๋ด ์ฝ๋ ๊ฐ๋ ์ฑ์ ์ํด ๊ณต์ ์ปจ๋ฒค์ ๊ณผ ๋ค๋ฅด๊ฒ, ์ผ๋ถ ๋ฆฐํธ๋ฅผ ์กฐ์ ํ ๋ถ๋ถ์ด ์์ผ๋ ์ฐธ๊ณ ํด์ฃผ์ธ์.
โ๏ธ ์ค์ ๋ฐฉ๋ฒ
์ด ํ๋ก์ ํธ๋ yaml ํ์ผ์ ๋ฐ๋ก .gitignore์ ๋ฑ๋กํ์ง ์์ต๋๋ค. ๋๋ฌธ์ ๊ฐ ๋ณ์์ ํด๋นํ๋ ๊ฐ๋ค์ ๋ชจ๋ Environment variables์์ ๊ด๋ฆฌํ๊ฒ ๋ฉ๋๋ค. env ํ์ผ์ ๋ง๋ค์ด์ ๊ด๋ฆฌํด๋ ๋ฌด๋ฐฉํฉ๋๋ค.
@param:Value("...") ์ฝ๋๊ฐ ํฌํจ๋ ๊ฒฝ์ฐ, ํด๋น ๋ณ์๋ฅผ IntelliJ ํ๊ฒฝ ๋ณ์์ ์ถ๊ฐํด์ฃผ์ด์ผ ํฉ๋๋ค.
Run/Debug Configurations → Modify options → Environment variables
ํ๊ฒฝ ๋ณ์๋ฅผ ์ค์ ํ๋ ๋ฐฉ๋ฒ์ ๋ค์ ์ฌ์ง๊ณผ ๊ฐ์ต๋๋ค.

ํ๊ฒฝ ๋ณ์์ ์ถ๊ฐํ ๋๋ง๋ค Secret์ ๋ฏธ๋ฆฌ ์ถ๊ฐํด๋๋ ๊ฒ์ด ์ข์ต๋๋ค.
Repository → Security → Actions → New repository secret

ํ๋ก์ ํธ ์์กด์ฑ ์ธํ
์ฃผ์ dependencies๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
dependencies {
// Github Apps ํ ํฐ ๋ฐํ ๊ด๋ จ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
// Google Gemini ๋ผ์ด๋ธ๋ฌ๋ฆฌ
implementation("com.google.genai:google-genai:1.0.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// Coroutine ๋ผ์ด๋ธ๋ฌ๋ฆฌ
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
}
application.yaml์๋ ๋ค์๊ณผ ๊ฐ์ ์ ๋ณด๊ฐ ํ์ํฉ๋๋ค. ๋ค๋ฅธ ๋ฐฉ๋ฒ์ผ๋ก ๊ด๋ฆฌํด๋ ๋ฌด๋ฐฉํฉ๋๋ค.
app:
github:
app:
app-id: ${APP_ID}
installation-id: ${APP_INSTALLATION_ID}
private-key: ${APP_PEM}
webhook:
secret-key: ${WEBHOOK_SECRET}
api:
token: ${API_TOKEN_GITHUB}
google:
api-key: ${GEMINI_API_KEY}
Github Webhooks ์์ฑ
Repository Settings ํญ์ ๋ค์ด๊ฐ Add webook์ ๋๋ฌ ์์ฑํด์ค๋๋ค.
| Type | Content |
|---|---|
| Payload URL | ์น ํ ์ด ๋ฐ์ํ์ ๋, ์์ฒญ์ ๋ณด๋ผ URL |
| Content type | Payload URL์ ๋ณด๋ผ ๋ฐ์ดํฐ์ Content type |
| Secret | ์ ์ถํ๊ธฐ ์ด๋ ค์ด ์์์ UUID |
| SSL verification | ๋ณธ์ธ์ ๋๋ฉ์ธ์ https๋ฅผ ์ ์ฉํ๋ค๋ฉด Enable ์ ํ |
| Trigger | Let me select individual events๋ฅผ ๋๋ฌ Pull requests ์ฒดํฌ |

์ด ๊ณผ์ ์ ๋ง์น๋ฉด PR์ ์์ฑํ ๋, ๋ณธ์ธ์ EC2 ์ธ์คํด์ค์ ์ฌ๋ผ๊ฐ์๋ ์๋ฒ์ ์น ํ ์ ๋ณด๋ด๊ฒ ๋ฉ๋๋ค.
Controller
Webhook ๊ฒ์ฆ์ ์ํด GitHub์์ ๋ฐ๊ธ๋ฐ์ Secret ๊ฐ์ ํ๊ฒฝ ๋ณ์(WEBHOOK_SECRET)๋ก ๋ฑ๋กํฉ๋๋ค.
๊ธฐ๋ณธ์ ์ธ ํ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค. ๋ฐ์์ ์ค๋ช
ํ ๋จ์ํ ์ฝ๋ ์กฐ๊ฐ๋ค์ net ํจ์ ๋ด๋ถ ๊ตฌํ์
๋๋ค. ๊ทธ ์ธ์ ๊ฒฝ์ฐ Controller ์ฝ๋๊ฐ ์๋ ๋ค๋ฅธ ํ์ผ์
๋๋ค.
@RestController
class CodeReviewController(
@param:Value("\${app.github.webhook.secret-key}")
private val secret: String,
private val codeReviewService: CodeReviewService
) {
@PostMapping("/api/code/review")
suspend fun net(
@RequestHeader("X-GitHub-Event") event: String,
@RequestHeader("X-Hub-Signature-256") sig256: String?,
@RequestBody rawBody: ByteArray
): ResponseEntity<String> {
return ResponseEntity.ok("Request completed")
}
private fun unauthorized(
message: String
) = ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(message)
}
GithubSignature validation
Webhook ์์ฒญ ์ ๊ธฐ๋ณธ์ ์ธ ๋ฐ์ดํฐ๊ฐ ๋ชจ๋ ๋ค์ด์๋์ง ํ์ธํ์ฌ, ์์กฐ ์ฌ๋ถ๋ฅผ ์ฐ์ ํ๋ณํฉ๋๋ค.
if (sig256 == null) {
logger.warn("Missing signature header for GitHub webhook")
return unauthorized("Missing signature")
}
if (!GithubSignature.isValid(sig256, secret, rawBody)) {
logger.warn("Invalid signature detected from GitHub webhook")
return unauthorized("Invalid signature")
}
์์ฒญ ๋ณธ๋ฌธ(payload)๊ณผ ๋ฏธ๋ฆฌ ์ค์ ํ ๋น๋ฐ ํค(secret)๋ฅผ ์ด์ฉํด HMAC-SHA256 ์๋ช ์ ์์ฑํ๊ณ , GitHub๊ฐ ๋ณด๋ธ ์๋ช (X-Hub-Signature-256)๊ณผ ์์ ์๊ฐ ๋น๊ต(constant-time comparison) ๋ฐฉ์์ผ๋ก ์ผ์น ์ฌ๋ถ๋ฅผ ๊ฒ์ฌํฉ๋๋ค.
์์ธํ ๋ด์ฉ์ Webhook ์ ํจ์ฑ ๊ฒ์ฌ ๊ณต์ ๋ฌธ์๋ฅผ ์ฐธ๊ณ ํด์ฃผ์ธ์.
object GithubSignature {
fun isValid(
signatureHeader: String?,
secret: String,
payload: ByteArray
): Boolean {
if (signatureHeader.isNullOrBlank()) return false
val expected = sign256(secret, payload)
return constantTimeEquals(expected, signatureHeader)
}
private fun sign256(
secret: String,
payload: ByteArray
): String {
...
}
private fun constantTimeEquals(a: String, b: String): Boolean {
...
}
}
Payload parsing
GitHub Webhook์ JSON ๋ณธ๋ฌธ์์ ํ์ํ ๋ฐ์ดํฐ๋ง ์ถ์ถํ๊ธฐ ์ํด DTO๋ฅผ ์ ์ํฉ๋๋ค. @JsonIgnoreProperties(ignoreUnknown = true)๋ฅผ ์ฌ์ฉํด ๋ถํ์ํ ํ๋๋ ์๋์ผ๋ก ๋ฌด์ํฉ๋๋ค.
val payload = parsePayload(rawBody)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GithubPayload(
val action: String,
val number: String,
val pull_request: PullRequestPayload
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PullRequestPayload(
val url: String,
val head: PullRequestHeadPayload
) {
private val _urls = url.removePrefix("https://").split("/")
val owner = _urls[2]
val repo = _urls[3]
val prNumber = _urls[5]
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class PullRequestHeadPayload(
val sha: String
)
val mapper = jacksonObjectMapper()
fun parsePayload(rawBody: ByteArray): GithubPayload {
return mapper.readValue(rawBody)
}
Request review
์ด์ , payload์ ํ์ํ ์ ๋ณด๋ฅผ ๋ฐ์์์ผ๋, pull_request์ด๊ณ , opened ์ํ์ธ ๊ฒฝ์ฐ์ ๋ฆฌ๋ทฐ ์์ฒญ์ ๋ณด๋
๋๋ค.
val action = GithubActionType(payload.action)
logger.info("payload = {}, action = {}", payload, action)
// PR ์์ฑ ์์๋ง ๋ฆฌ๋ทฐ ์งํ
when (event) {
"pull_request" -> when (GithubActionType(payload.action)) {
GithubActionType.OPENED -> codeReviewService.review(payload)
else -> logger.info("Ignored pull_request event: ${payload.action}")
}
else -> logger.debug("Unhandled GitHub event: $event")
}
enum class GithubActionType(val value: String) {
ASSIGNED("assigned"),
// ์ผ๋ถ ํ์
์ค๋ต
OPENED("opened"),
;
companion object {
operator fun invoke(value: String) = entries.find { it.value == value }
}
}
Service
๊ธฐ๋ณธ์ ์ธ ํ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค. ์๋์ ์ค๋ช ํ ์ธ๋ถ ๊ตฌํ์ review ํจ์ ๋ด๋ถ ๋ก์ง์ ๋๋ค. ์ด ํฌ์คํ ์์๋ ๊ตฌ์กฐํ๋ ๋์์ฑ์ด ์๋, ๋จ์ ์ฝ๋ฃจํด ์ค์ฝํ ๊ธฐ๋ฐ์ผ๋ก ๊ตฌํํ์ต๋๋ค.
@Service
class CodeReviewService {
private val scope = CoroutineScope(Dispatchers.IO)
fun review(payload: GithubPayload) {
}
}
PR ์ฝ๋ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
PR์ ์ฌ๋ผ์จ ์ฝ๋๋ค์ ์ ๋ณด๋ฅผ ๋ฐ์ต๋๋ค. ์ด ๊ณผ์ ์์ repo์ ๋ํ ์ ๊ทผ ๊ถํ์ ๊ฐ์ง GitHub Personal Access Token์ด ํ์ํฉ๋๋ค.
val diff = payload.pull_request.run {
githubDiffClient.getPrDiff(owner, repo, prNumber)
}
val parts = GithubDiffUtils.splitDiffByFile(diff)
@Service
class GithubDiffClient(
@param:Value("\${app.github.api.token}") private val token: String,
) {
private val client = WebClient.builder()
.baseUrl("https://api.github.com")
.defaultHeader("Accept", "application/vnd.github.v3.diff")
.build()
fun getPrDiff(
owner: String,
repo: String,
prNumber: String
): String = client.get()
.uri("/repos/$owner/$repo/pulls/$prNumber")
.header("Authorization", "Bearer $token")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
}
๊ฐ์ ธ์จ ๋ฐ์ดํฐ์ hunk๋ฅผ ๋ถ์ํฉ๋๋ค. ์ด ๋, ํด๋น ํ์ผ(filePath), ๋ณ๊ฒฝ ์ฌํญ(content), ๋ง์ง๋ง ๋ผ์ธ(line) ์ ๋ณด๋ฅผ ์ ์ฅํฉ๋๋ค.
object GithubDiffUtils {
data class FileDiff(
val filePath: String,
val content: String,
val line: Int
)
private fun findLastChangedLine(diffContent: String): Int {
...
}
fun splitDiffByFile(
diff: String
): List<FileDiff> = diff.split("diff --git")
.drop(1)
.mapNotNull { chunk ->
...
FileDiff(filePath, content, lastChangedLine)
}
}
Gemini์ ์ฝ๋ ๋ฆฌ๋ทฐ ์์ฒญ
parts.forEach { part ->
scope.launch {
try {
val prompt = "```diff\\n${part.content}\\n```"
val review = googleGeminiClient.chat(prompt)
githubReviewClient.addReviewComment(
GithubReviewDto(payload.pull_request, part, review)
)
} catch (e: Exception) {
logger.error("[Review Failed] file = ${part.filePath}", e)
}
}
}
์ด์ , part์ ๋ด๊ธด content๋ฅผ ๊ธฐ๋ฐ์ผ๋ก Gemini์๊ฒ ์ฝ๋ ๋ฆฌ๋ทฐ ์์ฒญ์ ๋ณด๋
๋๋ค. ์ฌ๊ธฐ์ app.google.api-key๋ Google AI Studio์์ ๋ฐ๊ธ ๋ฐ์ ๊ฐ์ ์๋ฏธํฉ๋๋ค.
SYSTEM_PROMPT๋ ๋ณธ์ธ์ด ๋ฆฌ๋ทฐ๋ฅผ ์ํ๋ ํ๋กฌํํธ๋ฅผ ์
๋ ฅํด์ฃผ๋ฉด ๋ฉ๋๋ค. ์๋ฅผ ๋ค์ด, ์ญํ ์ ์ง์ ํด์ฃผ๊ณ , ์ด๋ค ๋ถ๋ถ ์์ฃผ๋ก ์ฝ๋ ๋ฆฌ๋ทฐ๋ฅผ ํด๋ฌ๋ผ๋ ๋ฑ์ ๋ํ ํ๋กฌํํธ๋ฅผ ์
๋ ฅํด์ฃผ๋ฉด ๋ฉ๋๋ค.
@Component
class GoogleGeminiClient(
@param:Value("\${app.google.api-key}") val apiKey: String
) {
private val instruction = Content.builder()
.role("system")
.parts(
listOf(
Part.builder()
.text(SYSTEM_PROMPT)
.build()
)
)
.build()
private val think = ThinkingConfig.builder()
.thinkingBudget(500)
.build()
private val clientPool = ConcurrentHashMap<String, Client>()
private fun getClient(filePath: String): Client {
return clientPool.computeIfAbsent(filePath) {
Client.builder().apiKey(apiKey).build()
}
}
suspend fun chat(filePath: String, prompt: String): String? = withContext(Dispatchers.IO) {
val client = getClient(filePath)
try {
client.models.generateContentStream(
MODEL, prompt,
GenerateContentConfig.builder()
.systemInstruction(instruction)
.thinkingConfig(think)
.build()
).use { stream ->
buildString {
for (response in stream) append(response.text())
}
}
} catch (e: Exception) {
null
} finally {
clientPool.remove(filePath)
}
}
}
PR ์ฝ๋ฉํธ ๋จ๊ธฐ๊ธฐ
์ฝ๋ฉํธ๋ฅผ ๋จ๊ธธ ๋, ๊ฐ์ธ Token์ ์ฌ์ฉํ ๊ฒฝ์ฐ, ๋ณธ์ธ์ ํ๋กํ๋ก ๋ฆฌ๋ทฐ๊ฐ ๋ฌ๋ฆฝ๋๋ค. ์ฌ๋ฌ ๋ช ์ด์ ์์ ํ๋ ๊ฒฝ์ฐ, ์คํด์ ์์ง๊ฐ ์๊ธฐ ๋๋ฌธ์, Github Apps๋ฅผ ํตํด Bot์ ๋ง๋ค์ด ๋ฆฌ๋ทฐ๋ฅผ ๋ฌ๋๋ก ํฉ๋๋ค.
@Service
class GithubReviewClient(
private val tokenProvider: GithubAppTokenProvider
) {
data class ReviewCommentRequest(
val body: String,
val path: String,
val commit_id: String,
val subject_type: String = "file"
)
val client = WebClient.builder()
.baseUrl("https://api.github.com")
.defaultHeader("Accept", "application/vnd.github+json")
.defaultHeader("Authorization", "Bearer ${tokenProvider.getInstallationToken()}")
.build()
suspend fun addReviewComment(
dto: GithubReviewDto
) {
val uri = dto.payload.run {
"/repos/$owner/$repo/pulls/$prNumber/comments"
}
client.post()
.uri(uri)
.bodyValue(dto.toReviewCommentRequest())
.retrieve()
.bodyToMono(String::class.java)
.block()
}
}
Github Apps ์์ฑ
New Github App์ ๋ค์ด๊ฐ ์๋ก์ด App์ ์์ฑํด์ค๋๋ค. ์ด App์ ๋ฆฌ๋ทฐ ์์ฒญ ์ ์ฌ์ฉํ ํ ํฐ ๋ฐ๊ธ์ฉ์ผ๋ก๋ง ์ฌ์ฉ๋๋ฏ๋ก ํ์ ํญ๋ชฉ๋ค๋ง ๊ฐ๋ตํ๊ฒ ์ ๋ ฅ์ ํด์ค๋๋ค.
์ด๋ฏธ ์ฒดํฌ๊ฐ ๋์ด์๋ ํญ๋ชฉ์ ๋ชจ๋ ํด์ ํ๊ณ , Homepage URL ๋ํ ์๋ฌด ์ฃผ์๋ ์ ๋ ฅํด๋ ๋ฌด๋ฐฉํฉ๋๋ค. Permissions ๋ถ๋ถ์ Repository permissions ์ค Pull requests๋ง Read and write๋ก ์ค์ ํด์ฃผ๋ฉด ๋ฉ๋๋ค.
App์ ์์ฑํ ํ, ๋ค์ ํญ๋ชฉ๋ค์ ๊ฐ์ ธ์ ํ๊ฒฝ ๋ณ์์ Repository Secret์ ์ถ๊ฐํด์ค๋๋ค.
- App ID
- Installation ID
- ๋ชจ๋ ์ค์ ํ, Install App ํญ์ ๋ค์ด๊ฐ์ ์ค์น
- ์ค์น ํ ํฑ๋๋ฐํด ๋ชจ์์ ๋๋ฌ Integrations์ ์ง์
- URL์ ์ซ์๊ฐ Installation ID๋ฅผ ์๋ฏธ
https://github.com/settings/installations/123456ํํ์
- Private key(์๋ ๋ด์ฉ์ ๋ฐ๋ผ์ ์ ์ฉ)


Generate a private key๋ฅผ ๋ฐ๊ธ๋ฐ์ผ๋ฉด, pem ํ์ผ์ด ๋ค์ด๋ก๋ ๋ ํ
๋ฐ, ์ด ํ์ผ์ ๋ค์๊ณผ ๊ฐ์ด ์์ํฉ๋๋ค. -----BEGIN RSA PRIVATE KEY-----
์ด ํค์ ํ์์ PKCS#1์ธ๋ฐ, Java ๊ณ์ด์์ ๊ธฐ๋ณธ์ ์ผ๋ก ์ ๊ณตํ๋ ๊ฒ์ PKCS#8์ด๋ค. ๋๋ฌธ์, ๋ค์ ๋ช ๋ น์ด๋ฅผ ์ฌ์ฉํด PKCS#1๋ฅผ PKCS#8 ํค๋ก ๋ณํํด์ค๋๋ค.
openssl pkcs8 -topk8 -inform PEM -outform PEM -in private-key.pem -out private-key-pkcs8.pem -nocrypt
์ด ๋ช
๋ น์ด๋ฅผ ์ฌ์ฉํ๋ฉด ํ์ผ ํค๋๊ฐ -----BEGIN PRIVATE KEY-----๋ก ๋ฐ๋ ๊ฒ์ ๋ณผ ์ ์์ ๊ฒ์ด๋ค. ์ด์ ์ด ๊ฐ์ ํ๊ฒฝ ๋ณ์์ secret์ ์ถ๊ฐํด์ฃผ์ด์ผ ํ๋๋ฐ, secret์๋ ๊ทธ๋๋ก ๋ฃ์ด๋ ๋์ง๋ง, ํ๊ฒฝ ๋ณ์์๋ ์ฌ๋ฌ ์ค์ ์ ์งํด์ ๋ฃ์ ์ ์๊ธฐ ๋๋ฌธ์ ๊ฐ ๋ผ์ธ ๋์ \n๋ฅผ ํฌํจํ์ฌ ํ ์ค๋ก ๋ง๋ค์ด์ ๋ฃ์ด์ฃผ๋ฉด ๋ฉ๋๋ค.
@Component
class GithubAppTokenProvider(
@param:Value("\${app.github.app.app-id}") private val appId: String,
@param:Value("\${app.github.app.installation-id}") private val installationId: String,
@param:Value("\${app.github.app.private-key}") private val appPem: String
) {
@Volatile private var cachedToken: String? = null
@Volatile private var expiresAt: Instant? = null
private val privateKey = run {
val pem = appPem.replace("\\n", "\n")
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("\\s+".toRegex(), "")
val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(pem))
KeyFactory.getInstance("RSA").generatePrivate(keySpec)
}
private fun generateJwt(): String {
val now = Instant.now()
return Jwts.builder()
.setIssuer(appId)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plusSeconds(9 * 60)))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact()
}
fun getInstallationToken(): String {
val now = Instant.now()
if (cachedToken != null && expiresAt?.isAfter(now.plusSeconds(30)) == true) {
return cachedToken!!
}
val jwt = generateJwt()
val resp = WebClient.builder()
.baseUrl("https://api.github.com")
.defaultHeader("Authorization", "Bearer $jwt")
.defaultHeader("Accept", "application/vnd.github+json")
.build()
.post()
.uri("/app/installations/$installationId/access_tokens")
.retrieve()
.bodyToMono(Map::class.java)
.block()!!
cachedToken = resp["token"] as String
expiresAt = Instant.parse(resp["expires_at"] as String)
return cachedToken!!
}
}
์ด ๊ณผ์ ๊น์ง ์๋ฃ๋๋ฉด, Pull Request๊ฐ ์์ฑ๋ ๋ ์๋์ผ๋ก Gemini๊ฐ diff๋ฅผ ๋ถ์ํ๊ณ , Github App ๊ณ์ ์ผ๋ก ๋ฆฌ๋ทฐ ์ฝ๋ฉํธ๋ฅผ ๋จ๊ธฐ๊ฒ ๋ฉ๋๋ค.

๐ค ํ๊ณ
๋ชจ๋ ๋ด์ฉ์ ์ ๋ฆฌํ๋ฉด ์ข์ ๊ฒ ๊ฐ์๋ฐ, ์ ์ฒด์ ์ธ ํ๋ฆ๋ง ๋ด์๊ฒ ์กฐ๊ธ์ ์์ฌ์ด ๊ฒ ๊ฐ๋ค. ํ์ผ ํ๋ํ๋์ ๋ง์ ๋ด์ฉ์ด ์์ด, ์ญํ ์ ๋ ๋ถ๋ฆฌํ๋๋ก ๋ฆฌํฉํฐ๋ง์ด ํ์ํ ๊ฒ ๊ฐ๋ค.
'Devlog > Project' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| [LIBRARY] Java, Kotlin ๊ธฐ๋ฐ Spring ํ๊ฒฝ์์ ์ฌ์ฉ ๊ฐ๋ฅํ ๋น์์ด ํํฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ (1) | 2026.01.14 |
|---|---|
| [AI] LM Studio์ n8n์ผ๋ก Gitlab ์ฝ๋ ๋ฆฌ๋ทฐ ๋ด ๋ง๋ค๊ธฐ (0) | 2025.10.21 |