Go 1.26 Source-Level Inliner Review: Goodbye Manual Refactoring?
시니어 엔지니어로 일하다 보면 가장 짜증나는 작업 중 하나가 바로 사내 공통 라이브러리의 Deprecation 처리다. 슬랙 채널에 “제발 새 API로 마이그레이션 해주세요”라고 공지를 올리고, 몇 주 뒤에 여전히 레거시 함수를 호출하는 팀들의 리포지토리를 찾아가 일일이 PR을 날려주는 지루한 춤판 말이다.
이런 고통을 줄여주기 위해 Go 1.26이 아주 흥미로운 무기를 들고 나왔다. 바로 //go:fix inline 디렉티브를 활용한 소스 레벨 인라이너(Source-level inliner)다.
처음 이 소식을 접했을 때 내 반응은 “음, IDE에 흔히 있는 뻔한 리팩토링 기능이네” 였다. 하지만 Go 블로그에 공개된 내부 동작 원리와 에지 케이스(Edge case) 처리 방식을 뜯어보니, 이건 단순한 텍스트 치환 도구가 아니었다. 거의 컴파일러에 준하는 정적 분석 기술이 녹아들어 있었다.
소스 레벨 인라이닝의 핵심
일반적으로 인라이닝(Inlining)이라고 하면 컴파일러가 성능 최적화를 위해 중간 표현(IR) 단계에서 함수 호출부를 함수 본문으로 치환하는 것을 떠올린다. 하지만 Go 1.26의 인라이너는 소스 코드 자체를 영구적으로 수정한다.
가장 직관적인 예시는 Go 1.16에서 Deprecated 된 ioutil.ReadFile을 os.ReadFile로 마이그레이션 하는 케이스다.
package ioutil
import "os"
//go:fix inline
func ReadFile(filename string) ([]byte, error) {
return os.ReadFile(filename)
}
이렇게 기존 함수에 //go:fix inline 주석을 달아두고 클라이언트 코드에서 go fix -diff ./...를 실행하면, 기존 호출부가 새로운 함수 호출로 깔끔하게 치환된다.
단순히 함수 이름만 바꾸는 것이 아니다. 파라미터 순서가 잘못 설계된 과거의 API를 바로잡거나, 특정 상수나 타입을 새 패키지로 포워딩하는 작업도 이 디렉티브 하나로 자동화할 수 있다.
겉보기엔 쉽지만 속은 지옥인 6가지 에지 케이스
단순히 함수 본문을 복사해서 붙여넣고 파라미터를 인자로 바꾸면 끝날 것 같지만, 프로그래밍 언어의 세계는 그렇게 호락호락하지 않다. Go 팀은 이 도구를 구현하기 위해 약 7,000줄의 컴파일러급 로직을 작성했다고 한다. 내가 가장 인상 깊게 본 몇 가지 난제를 소개한다.
- Side Effects: 함수 호출 시 인자들의 평가 순서(Evaluation order)가 바뀌면 프로그램의 동작이 달라질 수 있다. 예를 들어
add(f(), g())를 인라이닝 했을 때, 만약f()와g()가 서로에게 영향을 주는 부수 효과(Side effect)를 가지고 있다면 단순 치환은 위험하다. 인라이너는 내부적으로 Hazard analysis를 수행하여 안전함을 증명하지 못하면var x = f()형태로 명시적인 변수 바인딩을 강제한다. - Fallible Constants:
return s[i]라는 함수에""와0을 인라이닝 하면""[0]이 된다. 런타임에는 이 코드가 호출되지 않아 문제가 없었더라도, 인라이닝 후에는 컴파일 타임 상수로 평가되어 빌드 에러를 뿜어낸다. 인라이너는 이런 제약 조건(Constraint system)을 추적해 안전할 때만 상수를 치환한다. - Defer Statement: 호출되는 함수 내부에
defer가 있다면 어떻게 될까? 단순 치환해버리면defer의 실행 시점이 호출자(Caller) 함수가 종료될 때로 밀려버린다. 이를 방지하려면 즉시 실행 함수(IIFE) 형태로 묶어야 하는데(func() { defer f() ... }()), 배치 도구인go fix는 코드가 지저분해지는 것을 막기 위해 이런 경우 아예 인라이닝을 거부하도록 정책을 정했다.
이러한 처리 방식을 보면 Go 팀이 이 도구를 단순히 ‘빠른 코드 수정’을 넘어, 코드의 동작을 절대 망가뜨리지 않는 Tidiness optimizing compiler로 접근했음을 알 수 있다.
Hacker News의 반응과 나의 생각
이 기능이 발표된 후 Hacker News에서는 꽤나 열띤 토론이 벌어졌다. 가장 논란이 된 부분은 바로 문법(Syntax) 디자인이다.
일부 개발자들은 “왜 전용 문법이나 어트리뷰트(Attribute) 시스템을 만들지 않고 //go:fix 같은 주석(Comment)을 오용(Kludge)하느냐”며 강하게 비판했다. Rust의 매크로나 Java의 어노테이션에 익숙한 사람들에겐 주석이 코드의 의미(Semantic)를 바꾸는 것이 불편하게 느껴질 수 있다.
하지만 내 생각은 다르다. Go의 이런 실용주의적 접근은 언제나 빛을 발한다. 주석을 메타데이터로 활용하면, 이 디렉티브를 모르는 구버전 컴파일러나 서드파티 툴체인에서도 완벽하게 하위 호환성이 유지된다. 언어 스펙을 복잡하게 만들지 않으면서도 강력한 툴링을 제공하는 Go 특유의 철학이 잘 묻어난 결정이라고 본다.
물론 커뮤니티에서 발견한 맹점들도 존재한다. 한 유저는 recover()를 사용하는 함수가 인라이닝 될 때 패닉 복구 시맨틱이 깨질 수 있다는 점을 지적했고, 제네릭(Generics)의 타입 추론 과정에서 unsafe.Sizeof와 결합될 때 발생하는 버그 리포트도 이미 깃허브에 올라오기 시작했다. 완벽한 안전성을 보장하기엔 아직 갈 길이 멀어 보인다.
결론: 프로덕션 레벨에서 쓸만한가?
구글처럼 수십억 줄의 모노레포(Monorepo)를 굴리는 환경에서는 이미 밤마다 로봇이 이 도구를 돌려 18,000개 이상의 레거시 코드를 날려버렸다고 한다. 하지만 중앙 통제가 불가능한 퍼블릭 오픈소스 생태계에서는 여전히 브레이킹 체인지(Breaking change)의 위험 때문에 사용이 제한적일 것이다.
그럼에도 불구하고 사내 마이크로서비스 간의 공통 모듈이나 내부 라이브러리를 관리하는 테크 리드들에게 이 기능은 축복이다. 더 이상 슬랙에서 제발 업데이트 해달라고 구걸할 필요가 없다. CI 파이프라인에 go fix -diff 검사를 넣고, 레거시를 쓰는 PR은 빌드를 터뜨려버리면 그만이다.
결론적으로 Go 1.26의 소스 레벨 인라이너는 단순한 리팩토링 툴을 넘어, 조직의 코드 컨벤션과 API 생명주기를 강제할 수 있는 강력한 거버넌스 도구다. 엣지 케이스에서의 자잘한 버그들만 수정된다면, 앞으로 Go 프로젝트의 유지보수 패러다임을 크게 바꿔놓을 것이다.
References
- Original Article: https://go.dev/blog/inliner
- Hacker News Thread: https://news.ycombinator.com/item?id=47339463