GROWTH LOG STORIES

함께 만든 성장을,
기록으로 연결합니다.

AI와 함께 배우고, 직접 만들고, 서로 나눈
그로스로그 멤버들의 생생한 성장 기록을 만나보세요.

성장 기록
5기+이어진 커뮤니티
HEALTHIO
PROJECT

사진 한 장으로 시작하는 건강 루틴, Health IO

대표 이야기 읽기

ALL STORIES

성장의 순간을
한곳에서 만나보세요.

프로젝트의 시작부터 모임에서 나눈 인사이트까지,
그로스로그가 함께 만든 기록을 모았습니다.
개의 성장 기록

성장일지

[GL 2기] 로컬 젠킨스에 깃허브 웹훅 연결하기, 그리고 코틀린 어노테이션이 안 먹던 이유 (성장일지 9회차 큐레이션) 🌱

GROWTH LOG🌱 2026. 8. 7. 04:33
GL 2기 성장일지 9회차 큐레이션

안녕하세요! 그로스로그 입니다!😊 🌱

깃허브가 내 컴퓨터를 못 찾는 문제를 뚫은 기록, 그리고 분명히 붙였는데 동작하지 않던 어노테이션의 원인을 바이트코드까지 내려가 밝힌 기록. 2기 9회차입니다.

성장일지는 멤버들이 2주에 한 번씩 자신의 성장을 기록하는 활동이에요. 매 회차 운영진이 모든 글을 읽고, 그중 특히 마음에 남은 글을 함께 골라 큐레이션합니다.

 

01

🔌 GitHub WebHooks — failed to connect to host 에러

이범수 님

이런 내용이에요
깃허브 웹훅을 젠킨스에 연결하려는데 Last delivery was not successful. failed to connect to host. 오류가 납니다.
원인은 젠킨스가 사설 IP를 쓰는 로컬 환경에 있다는 것. 깃허브 서버에서는 내 컴퓨터로 갈 길이 없습니다.
ngrok으로 로컬 서버를 외부에 노출해 문제를 해결하는 과정을 설치부터 테스트까지 단계별로 정리했습니다.

CI/CD를 처음 붙일 때 거의 모두가 만나는 벽입니다. 코드는 다 맞는데 네트워크 구조 때문에 안 되는 상황이요.

조금 더 들어가 보면

먼저 웹훅이 어떤 방향으로 움직이는지가 중요합니다.

우리는 보통 내가 서버에 요청하는 구조에 익숙합니다. 브라우저가 서버에 물어보고, 앱이 API를 부르죠. 그런데 웹훅은 방향이 반대입니다.

깃허브에 코드를 푸시하면, 깃허브가 나에게 전화를 겁니다. "방금 푸시됐어요, 빌드하세요"라고요. 젠킨스가 주기적으로 깃허브를 확인하는 게 아니라, 깃허브가 젠킨스를 찾아오는 구조예요.

여기서 문제가 생깁니다. 깃허브가 내 컴퓨터를 찾아올 수 있어야 하는데, 대부분의 개발 환경은 그럴 수 없거든요.

집이나 회사의 컴퓨터는 대개 사설 IP를 씁니다. 192.168.0.510.0.0.3 같은 주소요. 이건 공유기 안에서만 통하는 번호입니다. 아파트로 치면 호수는 있는데 건물 주소가 없는 상태예요. 같은 건물 사람은 찾아오지만, 밖에서는 못 옵니다.

그래서 failed to connect to host가 뜹니다. 깃허브 입장에서는 주소가 있는데 그 주소로 가는 길이 없는 거예요.

🧭 ngrok이 하는 일

해결 방법은 원래 여러 가지입니다. 공유기에 포트 포워딩을 걸거나, 방화벽을 열거나, 젠킨스를 클라우드 서버로 옮기거나요.

그런데 셋 다 부담이 있습니다. 회사 네트워크는 마음대로 못 건드리고, 포트를 여는 건 보안상 신중해야 하며, 서버를 띄우면 비용이 듭니다. 잠깐 테스트해보려는 것뿐인데 말이죠.

ngrok은 이 상황을 위한 도구입니다. 원리는 이렇습니다.

내 컴퓨터에서 ngrok을 실행하면, ngrok이 자기네 서버로 먼저 연결을 겁니다. 나가는 연결이니 방화벽에 막히지 않아요. 그리고 ngrok 서버는 공개 주소를 하나 발급해서, 거기로 오는 요청을 이미 열려 있는 통로로 되돌려 보냅니다.

핵심은 밖에서 안으로 들어오는 문을 여는 게 아니라, 안에서 밖으로 나간 길을 되쓰는 것입니다. 그래서 네트워크 설정을 안 바꿔도 됩니다.

📝 원문에서는 이렇게 진행합니다

설치와 인증 토큰 등록입니다.

brew install ngrok
ngrok version

# ngrok.com에서 발급받은 토큰 등록
ngrok config add-authtoken <YOUR_AUTH_TOKEN>

그리고 젠킨스가 쓰는 포트를 그대로 노출합니다.

ngrok http 9090

실행하면 이런 출력이 나옵니다.

Forwarding  http://abc123.ngrok.io  -> http://localhost:9090
Forwarding  https://abc123.ngrok.io -> http://localhost:9090

왼쪽이 세상에서 보이는 주소, 오른쪽이 내 컴퓨터입니다. 이제 깃허브에는 왼쪽 주소를 알려주면 돼요.

깃허브 저장소의 Settings → Webhooks → Add webhook에서 Payload URL에 이렇게 넣습니다.

https://abc123.ngrok.io/github-webhook/

끝의 /github-webhook/은 젠킨스가 웹훅을 받기로 정해둔 경로입니다. 마지막 슬래시까지 정확히 맞춰야 해요. 이 한 글자로 404가 나는 경우가 실제로 많습니다.

확인은 깃허브 웹훅 화면의 Recent Deliveries에서 합니다. Test push를 눌러 HTTP 200이 오면 성공이에요.

🧭 그리고 주의사항

글이 마지막에 짚은 게 중요합니다. 무료 버전은 세션이 8시간이고, 끊기면 다시 실행해야 합니다.

그런데 여기엔 함정이 하나 더 있어요. 다시 실행하면 주소가 바뀝니다. abc123xyz789가 되죠. 깃허브에 등록해둔 웹훅 주소는 그대로니까, 어제 되던 게 오늘 다시 안 됩니다.

그래서 ngrok은 개발용 도구로 이해하는 게 맞습니다. 로컬에서 웹훅이 제대로 오는지 확인하고, 젠킨스 설정이 맞는지 검증하는 용도요. 실제 운영에서는 고정 주소를 가진 서버에 젠킨스를 올리게 됩니다.

그렇다고 가치가 작은 건 아니에요. "내 설정이 틀린 건지, 네트워크가 문제인지"를 가려내는 것만으로도 큰 도움이 됩니다. 원인을 모르면 아무 데나 고치게 되니까요.

큐레이터 노트

원인을 정확히 짚었기 때문에 뽑았습니다.

failed to connect to host를 보고 대부분은 젠킨스 설정을 의심합니다. 플러그인을 다시 깔고, URL을 바꿔보고, 권한을 확인하죠. 그런데 이 글은 "사설 IP라서"를 원인으로 잡았습니다. 내 애플리케이션이 아니라 내가 놓인 네트워크 구조를 본 겁니다.

이 시야가 중요합니다. 개발하다 보면 코드 바깥에서 생기는 문제가 꽤 있어요. 방화벽, DNS, 프록시, 인증서 같은 것들이요. 이런 문제는 코드를 아무리 들여다봐도 안 풀립니다. 어디를 봐야 하는지 아는 것이 먼저예요.

정리 형식도 실용적입니다. 설치 → 인증 → 실행 → 웹훅 설정 → 테스트 → 주의사항. 똑같은 상황에 놓인 사람이 그대로 따라 할 수 있는 순서로 되어 있어요. 트러블슈팅 글이 갖춰야 할 형태입니다.

원문 읽으러 가기  ↗velog.io/@beomsu/Github-WebHooks-failed-to-connect-to-host…
 

02

🏷️ 코틀린의 @ModelAttribute와 커스텀 검증 어노테이션

오연수 님

이런 내용이에요
코틀린 데이터 클래스에 커스텀 유효성 검사 어노테이션을 붙이고 @Valid로 검증하려 했는데, 어노테이션이 아예 동작하지 않는 문제를 만납니다.
코드는 어디를 봐도 맞습니다. 어노테이션도 붙어 있고, @Valid도 있고, Validator도 등록되어 있어요.
저자는 디컴파일된 바이트코드를 열어 원인을 찾습니다. 그리고 해결은 @field: 한 단어였습니다.

"분명히 붙였는데 안 먹는다." 이 종류의 문제는 특히 답답합니다. 에러도 안 나고, 그냥 조용히 통과해버리거든요.

조금 더 들어가 보면

상황부터 보겠습니다. 결제 요청을 받는 데이터 클래스입니다.

data class PayRequest(
    val amount: Double,   // 결제 금액
    @CheckUserId
    val userId: Long,     // 사용자 ID
)

@CheckUserId는 직접 만든 어노테이션이고, 뒤에 실제로 그 사용자가 존재하는지 확인하는 Validator가 붙어 있습니다.

@Component
class CheckUserIdValidator(
    private val userDQryBus: IUserDomainQueryBus
) : ConstraintValidator<CheckUserId, Long> {
    override fun isValid(value: Long, context: ConstraintValidatorContext?): Boolean {
        return userDQryBus.existById(value)
    }
}

컨트롤러에서도 @Valid를 제대로 붙였습니다.

@PostMapping("/pay")
fun processPayment(@Valid @ModelAttribute payRequest: PayRequest) {
    //
}

전부 맞습니다. 그런데 존재하지 않는 사용자 ID를 넣어도 그냥 통과합니다.

🧭 코틀린이 어노테이션을 어디에 붙이는가

원인은 코틀린과 자바의 차이에 있었습니다.

자바에서 필드에 어노테이션을 붙이면 그냥 필드에 붙습니다. 헷갈릴 게 없어요. 그런데 코틀린의 주 생성자 프로퍼티는 여러 개의 자바 요소로 한 번에 번역됩니다.

val userId: Long 한 줄이 자바로 가면 이렇게 나뉩니다.

· 생성자 파라미터 long userId

· private 필드 userId

· getter getUserId()

셋 다 만들어져요. 그러면 어노테이션은 이 중 어디에 붙을까요? 코틀린의 기본 규칙은 생성자 파라미터입니다.

디컴파일해보면 그대로 드러납니다.

public final class PayRequest {
    private final double amount;
    private final long userId;

    public PayRequest(double amount, @CheckUserId long userId) {
        this.amount = amount;
        this.userId = userId;
    }
}

@CheckUserId생성자 괄호 안에만 있습니다. 필드에도, getter에도 없어요.

그런데 스프링의 @Valid는 필드나 getter에 붙은 어노테이션만 봅니다. 생성자 파라미터는 확인 대상이 아니에요. 그래서 어노테이션이 분명히 존재하는데도 검증이 일어나지 않았던 겁니다.

아무도 틀리지 않았는데 결과가 틀린 상황입니다. 코틀린은 규칙대로 붙였고, 스프링도 규칙대로 봤어요. 두 규칙이 어긋난 지점에 버그가 있었던 거죠.

📝 원문에서는 이렇게 고칩니다

해결은 @field: 한 단어입니다.

data class PayRequest(
    val amount: Double,
    @field:CheckUserId
    val userId: Long
)

이걸 코틀린에서 use-site target(적용 대상 지정)이라고 부릅니다. "이 어노테이션은 필드에 붙여줘"라고 명시하는 문법이에요.

고친 뒤의 바이트코드를 보면 위치가 바뀝니다.

public final class PayRequest {
    private final double amount;
    @CheckUserId
    private final long userId;

    public final double getAmount() { return this.amount; }
    public final long getUserId() { return this.userId; }
    ...
}

이제 @CheckUserId필드 위에 있습니다. 스프링이 볼 수 있는 자리로 옮겨진 거예요.

@field: 말고도 @get:, @param:, @setparam: 같은 것들이 있습니다. 라이브러리마다 어디를 보는지가 달라서, JPA 어노테이션이 안 먹거나 Jackson 설정이 무시되는 문제도 대개 같은 원인입니다. 이 개념 하나를 알면 여러 상황이 한 번에 풀려요.

🧭 바이트코드를 열어봤다는 것

이 글에서 가장 배울 만한 건 원인을 찾은 방법입니다.

어노테이션이 안 먹을 때 보통은 이렇게 합니다. 오타를 확인하고, @Component가 붙었는지 보고, 의존성을 확인하고, 검색을 해보죠. 전부 소스 코드 층위에서 하는 일입니다.

그런데 이 문제는 소스 코드에 답이 없었습니다. 소스는 정확했으니까요. 답은 컴파일된 결과물에 있었습니다.

"내가 쓴 코드"와 "실제로 실행되는 코드"는 다를 수 있다 — 코틀린처럼 자바 위에서 도는 언어를 쓸 때 특히 그렇습니다. 데이터 클래스 한 줄이 자바로 가면 필드, getter, equals, hashCode, toString, copy까지 만들어져요. 편하지만, 그 변환 과정을 모르면 원인을 못 찾는 문제가 생깁니다.

IntelliJ에서는 Tools → Kotlin → Show Kotlin Bytecode로 열고 Decompile을 누르면 바로 볼 수 있습니다. 어렵지 않아요. 그런데 거기까지 가볼 생각을 했다는 게 이 글의 값입니다.

큐레이터 노트

한 층 아래로 내려가 답을 찾았기 때문에 뽑았습니다.

같은 층위에서 답이 안 나오면 층을 바꿔야 합니다. 소스에 답이 없으면 바이트코드로, 애플리케이션에 답이 없으면 네트워크로요. 이 판단이 문제 해결의 속도를 가릅니다.

그리고 이 저자의 기록을 이어서 보면 흐름이 보입니다. 5회차에는 스프링 시큐리티의 ProviderManager 내부를, 6회차에는 클린 코드 강연을, 이번엔 코틀린의 컴파일 결과를 봤어요. 계속 "그 아래에서 무슨 일이 일어나는가"를 묻고 있습니다. 기록이 쌓이면서 사람의 방식이 드러나는 좋은 사례입니다.

해결이 한 단어라는 점도 좋았습니다. 고생해서 찾은 답이 짧으면 허탈할 수도 있는데, 그 한 단어에 도달하기까지의 과정을 다 적어두셨어요. 답만 적힌 글은 검색되면 끝이지만, 과정이 적힌 글은 방법을 남깁니다.

원문 읽으러 가기  ↗devlemon.tistory.com/19

💡 두 편을 겹쳐 읽으며

두 글의 증상이 닮았습니다. 둘 다 "코드는 맞는데 안 된다"입니다.

젠킨스 설정은 정확했습니다. 문제는 깃허브가 내 컴퓨터로 올 길이 없다는 것이었어요. 어노테이션도 정확히 붙어 있었습니다. 문제는 그게 스프링이 보지 않는 자리에 붙었다는 것이었고요.

둘 다 "내가 쓴 것"과 "실제로 동작하는 환경" 사이의 간격에서 생긴 문제입니다.

우리는 코드를 쓸 때 그게 그대로 실행된다고 가정합니다. 그런데 실제로는 사이에 여러 겹이 있어요. 컴파일러가 변환하고, 프레임워크가 해석하고, 네트워크가 전달하고, 방화벽이 걸러냅니다. 평소에는 이 겹들이 잘 작동해서 없는 것처럼 느껴지죠.

문제는 그 겹 중 하나가 내 기대와 다르게 동작할 때 생깁니다. 그리고 그때는 코드를 아무리 봐도 답이 안 나옵니다. 코드에는 잘못이 없으니까요.

그래서 두 글이 한 일이 같습니다. 눈을 코드에서 떼고 그 옆이나 아래를 본 것. 한 사람은 네트워크 구조를, 다른 한 사람은 컴파일 결과를 봤습니다.

"어디를 봐야 하는가"를 아는 것 — 경력이 쌓이면서 늘어나는 능력의 상당 부분이 여기에 있습니다.

이번 회차에서 가져갈 것

1. 안 될 때는 층을 바꿔보기. 소스 코드에서 답이 안 나오면 컴파일 결과, 네트워크, 설정, 환경변수 쪽으로 시선을 옮기세요. 같은 층에서 열 번 보는 것보다 한 층 내려가는 게 빠를 때가 많습니다.

2. 웹훅은 "받는 쪽"이 열려 있어야 한다. 깃허브가 나를 찾아오는 구조입니다. 사설 IP나 로컬 환경이면 애초에 도달할 수 없어요. ngrok 같은 도구로 확인하되, 주소가 바뀐다는 점은 기억해두세요.

3. 코틀린에서 어노테이션이 안 먹으면 @field:를 의심하기. 주 생성자 프로퍼티의 어노테이션은 기본적으로 생성자 파라미터에 붙습니다. 검증·JPA·직렬화 라이브러리가 무시하는 것 같다면 여기부터 확인해보세요.

4. 바이트코드는 생각보다 가깝다. IntelliJ에서 Show Kotlin BytecodeDecompile 두 번이면 열립니다. 내가 쓴 코드가 실제로 무엇이 되었는지 한 번 보고 나면, 언어를 대하는 감각이 달라집니다.


좋은 글 남겨주신 이범수 님, 오연수 님께 감사드립니다! 😊

다음 큐레이션으로 다시 찾아오겠습니다! 🌱

- 에디터 · 성장일지 큐레이터 -


※ 본 큐레이션은 각 저자가 공개한 글을 소개하는 것이며, 모든 원문의 저작권은 저자에게 있습니다. 저자 본인의 요청이 있을 경우 즉시 수정 또는 삭제합니다.

WRITE YOUR GROWTH

오늘의 배움을
내일의 성장으로.

기록은 나를 돌아보게 하고, 공유는 우리를 연결합니다.
당신의 다음 성장 이야기도 그로스로그에서 시작해보세요.

커뮤니티 둘러보기