이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.

Java를 수년간 써온 개발자가 Go를 처음 접하면 당혹스럽다. “왜 이렇게 만들었지?“라는 생각이 끊이지 않는다. 이 글에서는 Go의 설계 철학을 이해하고, Java에서 Go로의 전환이 언제 적절한지 판단할 수 있는 기준을 제시한다.


1. Go의 설계 철학: “Less is more”#

Go는 2009년 Google에서 Robert Griesemer, Rob Pike, Ken Thompson이 만들었다. 세 사람 모두 수십 년 경력의 시스템 프로그래머로, C++의 복잡성에 지쳐 있었다. Go의 탄생 배경 자체가 “복잡함에 대한 반발"이다.

단순함을 위해 포기한 것들#

Go는 의도적으로 많은 기능을 빼버렸다.

제네릭의 제한적 지원: Go 1.18(2022년)에서야 제네릭이 추가됐고, 지금도 Java의 제네릭보다 표현력이 제한적이다. 와일드카드(? extends T)나 공변성/반공변성 같은 개념이 없다.

예외(Exception) 없음: try-catch-finally가 없다. 에러는 그냥 값이다. 함수가 (결과, error) 튜플을 반환하고, 호출자가 매번 체크한다.

상속 없음: extends, implements 키워드가 없다. 클래스 계층 구조를 설계할 수 없다.

어노테이션 없음: @Override, @Autowired 같은 메타데이터 마킹이 불가능하다. 리플렉션 기반 프레임워크의 “마법"이 작동하기 어렵다.

왜 이렇게 만들었을까?#

Go 팀의 철학은 명확하다: 코드는 작성되는 것보다 읽히는 횟수가 훨씬 많다. 따라서 작성 편의성보다 가독성을 우선한다.

Java Spring 프로젝트를 생각해보자. 새로 합류한 개발자가 코드를 이해하려면 어노테이션이 어떻게 처리되는지, AOP가 어디서 개입하는지, 빈 라이프사이클이 어떻게 되는지 알아야 한다. “마법"이 많을수록 학습 곡선이 가파르다.

Go는 이런 “마법"을 거의 허용하지 않는다. 코드가 하는 일이 그대로 보인다. 지루할 수 있지만, 6개월 후 돌아와서 읽어도 이해하기 쉽다.


2. OOP vs Go의 접근법#

상속 대신 컴포지션#

Java에서 흔히 보는 상속 구조를 생각해보자.

public abstract class Animal {
    protected String name;
    public abstract void speak();
}

public class Dog extends Animal {
    @Override
    public void speak() {
        System.out.println(name + " barks");
    }
}

Go에는 상속이 없다. 대신 구조체 임베딩(embedding)으로 비슷한 효과를 낸다.

type Animal struct {
    Name string
}

type Dog struct {
    Animal  // 임베딩
}

func (d Dog) Speak() {
    fmt.Println(d.Name + " barks")
}

DogAnimal을 “상속"하는 게 아니라 “포함"한다. d.Name으로 접근할 수 있는 건 문법적 편의일 뿐, 실제로는 d.Animal.Name이다.

이 차이가 왜 중요한가? 상속은 강한 결합을 만든다. 부모 클래스의 변경이 모든 자식에 영향을 미친다. 컴포지션은 더 느슨한 결합을 유지하면서 코드 재사용을 가능하게 한다.

암묵적 인터페이스 구현#

Java에서 인터페이스 구현은 명시적이다.

public interface Writer {
    void write(byte[] data);
}

public class FileWriter implements Writer {  // 명시적 선언
    @Override
    public void write(byte[] data) { ... }
}

Go에서는 implements 키워드가 없다. 메서드 시그니처만 맞으면 자동으로 인터페이스를 구현한 것으로 간주한다.

type Writer interface {
    Write(data []byte)
}

type FileWriter struct{}

func (fw FileWriter) Write(data []byte) { ... }  // 자동으로 Writer 구현

이를 “덕 타이핑(Duck Typing)“이라 부른다. “오리처럼 걷고 오리처럼 꽥꽥거리면 오리다.”

장점은 유연성이다. 표준 라이브러리의 io.Writer 인터페이스를 구현하기 위해 별도 import나 선언 없이, 그냥 Write([]byte) (int, error) 메서드만 구현하면 된다.

단점은 명시성 부족이다. 어떤 struct가 어떤 interface를 구현하는지 코드만 봐서는 바로 알기 어렵다. IDE의 도움이 필요하다.


3. Java 개발자가 처음에 불편해하는 것들#

if err != nil 지옥#

Java 개발자가 Go 코드를 보면 가장 먼저 눈에 띄는 것이 반복되는 에러 체크다.

file, err := os.Open("config.json")
if err != nil {
    return nil, fmt.Errorf("failed to open config: %w", err)
}

data, err := io.ReadAll(file)
if err != nil {
    return nil, fmt.Errorf("failed to read config: %w", err)
}

var config Config
err = json.Unmarshal(data, &config)
if err != nil {
    return nil, fmt.Errorf("failed to parse config: %w", err)
}

Java라면 try 블록 하나로 감싸면 될 일을 Go에서는 매번 체크해야 한다. 처음엔 굉장히 번거롭게 느껴진다.

그러나 시간이 지나면 이게 장점이라는 걸 알게 된다. 에러가 어디서 발생했는지, 어떻게 전파되는지 코드만 봐도 명확하다. Java에서 NullPointerException 스택 트레이스를 따라가며 디버깅하던 경험을 떠올려보라.

제네릭의 한계#

Java에서 흔히 쓰는 패턴이 Go에서는 불가능하거나 어렵다.

// Java: 복잡한 타입 바운드
public <T extends Comparable<T>> T max(List<T> items) { ... }

Go 제네릭은 이런 표현력이 떨어진다. 복잡한 제네릭 코드가 필요한 상황이라면 Go가 적합하지 않을 수 있다.

프레임워크 부재#

Java 생태계에는 Spring이라는 거대한 프레임워크가 있다. DI, AOP, 트랜잭션 관리, 보안 등을 종합적으로 제공한다.

Go에는 Spring 같은 “올인원” 프레임워크가 없다. 의도적으로 그렇다. Go 커뮤니티는 작은 라이브러리들을 조합해서 쓰는 것을 선호한다. Gin(HTTP), GORM(ORM), Wire(DI) 등을 직접 조합해야 한다.

장점은 자유도다. 단점은 매번 조합을 직접 해야 하고, 베스트 프랙티스가 팀마다 다를 수 있다는 것이다.


4. When to Go: 마이그레이션이 적절한 상황#

4.1 고성능 네트워크 서비스 / API Gateway / Proxy#

왜 Go가 적합한가:

Go는 네트워크 I/O 처리에 최적화되어 있다. Goroutine은 OS 스레드보다 훨씬 가볍다(초기 스택 2KB vs Java Virtual Thread도 수 KB 이상). 수십만 개의 동시 연결을 단일 프로세스로 처리할 수 있다.

Cloudflare, Netflix, Uber의 API Gateway들이 Go로 작성된 이유가 있다. 높은 동시성이 필요한 프록시, 로드밸런서, 리버스 프록시 같은 인프라 컴포넌트에 Go는 탁월하다.

Java와의 비교:

Java도 Virtual Thread(Project Loom) 도입으로 동시성 처리가 개선됐지만, 런타임 오버헤드와 메모리 사용량에서 Go가 여전히 유리하다. 특히 컨테이너 환경에서 메모리 128MB~512MB로 운영해야 하는 경우, JVM의 힙 관리 오버헤드가 부담이 된다.

적합한 케이스:

  • 초당 수만~수십만 요청을 처리하는 API Gateway
  • L4/L7 프록시, 로드밸런서
  • WebSocket 서버, 실시간 메시징 서버
  • gRPC 기반 서비스 메시

4.2 CLI 도구 및 DevOps 유틸리티#

왜 Go가 적합한가:

Go는 단일 바이너리로 컴파일된다. 의존성 없이 바이너리 하나만 배포하면 된다. Docker, Kubernetes, Terraform, Hugo 모두 Go로 작성됐다.

# 크로스 컴파일이 기본 지원
GOOS=linux GOARCH=amd64 go build -o myapp-linux
GOOS=darwin GOARCH=arm64 go build -o myapp-mac
GOOS=windows GOARCH=amd64 go build -o myapp.exe

Java와의 비교:

Java로 CLI 도구를 만들면 JRE가 필요하다. GraalVM Native Image로 네이티브 바이너리를 만들 수 있지만, 빌드 시간이 길고 리플렉션 설정이 까다롭다. Spring Boot 앱을 Native Image로 만들려면 상당한 노력이 필요하다.

적합한 케이스:

  • 내부 배포 도구, 자동화 스크립트
  • 인프라 관리 CLI (kubectl 플러그인 등)
  • 로그 분석, 데이터 변환 유틸리티

4.3 마이크로서비스의 사이드카 / 경량 서비스#

왜 Go가 적합한가:

마이크로서비스 환경에서 사이드카 패턴을 쓸 때, 각 Pod마다 붙는 프록시는 최대한 가벼워야 한다. Envoy(C++)나 Linkerd-proxy(Rust)처럼 Go로 작성된 사이드카도 메모리 10~50MB 수준으로 운영 가능하다.

또한 서비스 시작 시간이 중요하다. Go 바이너리는 밀리초 단위로 시작된다. Kubernetes에서 Pod가 빠르게 스케일 아웃되어야 할 때 JVM의 웜업 시간(수 초~수십 초)은 치명적일 수 있다.

적합한 케이스:

  • 서비스 메시 사이드카
  • 단순 CRUD + 비즈니스 로직이 적은 마이크로서비스
  • 람다/서버리스 함수 (콜드 스타트 최소화)

4.4 컨테이너/클라우드 네이티브 환경#

왜 Go가 적합한가:

Go 바이너리는 보통 10~20MB 수준이다. scratch 또는 distroless 베이스 이미지에 바이너리만 넣으면 최종 이미지가 20MB 이하가 된다.

FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]

이미지 크기가 작으면 pull 시간이 짧고, 스케일 아웃이 빠르다. 또한 공격 표면(attack surface)이 줄어 보안에도 유리하다.

Java와의 비교:

Spring Boot 앱을 최적화해도 이미지가 200MB 이상 나오기 쉽다. JRE만 100MB가 넘기 때문이다. 물론 Jib 같은 도구로 레이어 캐싱을 최적화할 수 있지만, 근본적인 크기 차이는 줄어들지 않는다.

적합한 케이스:

  • Kubernetes 환경에서 수백 개 Pod를 운영하는 경우
  • 엣지 컴퓨팅, IoT 게이트웨이 (리소스 제약 환경)
  • 빈번한 배포가 필요한 환경

4.5 시스템 프로그래밍 / 인프라 도구#

왜 Go가 적합한가:

Go는 시스템 프로그래밍 언어로 설계됐다. OS API에 직접 접근하기 쉽고, C 라이브러리와의 연동(cgo)도 지원한다. Docker, Kubernetes, etcd, Prometheus, Grafana 등 클라우드 인프라의 핵심 컴포넌트들이 Go로 작성된 이유다.

적합한 케이스:

  • 컨테이너 런타임, 오케스트레이션 도구
  • 모니터링 에이전트, 메트릭 수집기
  • 스토리지 시스템, 분산 시스템 컴포넌트

5. When NOT to Go: 마이그레이션이 득보다 실이 많은 상황#

5.1 복잡한 도메인 로직이 있는 엔터프라이즈 애플리케이션#

왜 Java가 더 나은가:

복잡한 비즈니스 도메인을 모델링할 때는 풍부한 타입 시스템과 OOP 기능이 도움이 된다. Java는 상속, 다형성, 제네릭, 어노테이션 등으로 도메인 모델을 정교하게 표현할 수 있다.

예를 들어 금융 도메인의 복잡한 상속 계층을 생각해보자.

public abstract class FinancialInstrument {
    protected Money notional;
    protected LocalDate maturityDate;
    public abstract Money calculatePresentValue(MarketData market);
}

public class Bond extends FinancialInstrument {
    private Rate couponRate;
    private Frequency paymentFrequency;
    // 복잡한 가격 계산 로직
}

public class Option extends FinancialInstrument {
    private OptionType type;
    private Money strikePrice;
    // Black-Scholes 모델 등
}

Go로 이런 도메인 모델을 표현하면 interface와 struct 조합으로 어느 정도 가능하지만, 코드가 더 장황해지고 타입 안전성이 떨어진다.

구체적으로 Java가 유리한 점:

  • 추상 클래스: Go에는 없다. 부분 구현을 공유하려면 임베딩과 인터페이스를 복잡하게 조합해야 한다.
  • 제네릭 표현력: 복잡한 타입 바운드(<T extends Comparable<T> & Serializable>)를 Go로 표현하기 어렵다.
  • 어노테이션 기반 검증: @NotNull, @Valid 같은 선언적 검증이 불가능하다. 코드로 직접 검증 로직을 작성해야 한다.
  • AOP: 횡단 관심사(로깅, 보안, 트랜잭션)를 깔끔하게 분리하기 어렵다.

적합하지 않은 케이스:

  • ERP, CRM 같은 복잡한 엔터프라이즈 애플리케이션
  • 금융 거래 시스템, 보험 상품 관리 시스템
  • 복잡한 워크플로우 엔진

5.2 Spring 생태계에 깊이 의존하는 경우#

왜 마이그레이션이 어려운가:

Spring은 단순한 프레임워크가 아니라 하나의 생태계다. Spring Security, Spring Data, Spring Batch, Spring Integration 등이 유기적으로 연결되어 있다.

Spring Security를 예로 들면:

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) {
        return http
            .oauth2Login()
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .build();
    }
}

이 몇 줄이 OAuth2 인증, 세션 관리, CSRF 보호, 권한 체크를 모두 처리한다. Go로 동일한 기능을 구현하려면:

  • OAuth2 라이브러리 선택 및 통합
  • 세션 스토어 구현 (Redis 연동 등)
  • 미들웨어 체인 직접 구성
  • 권한 체크 로직 직접 구현

Spring이 제공하는 “배터리 포함(batteries included)” 경험을 Go에서 재현하려면 상당한 노력이 필요하다.

Spring Batch의 경우:

Java에는 Spring Batch라는 검증된 배치 프레임워크가 있다. 청크 기반 처리, 재시작 가능성, 병렬 처리, 스킵/재시도 정책 등을 선언적으로 설정할 수 있다.

Go에는 이에 대응하는 프레임워크가 없다. 배치 처리 로직을 직접 구현해야 하고, 장애 복구, 체크포인팅 등을 수동으로 처리해야 한다.

적합하지 않은 케이스:

  • Spring Security로 복잡한 인증/인가를 구현한 시스템
  • Spring Batch로 대규모 배치 처리를 하는 시스템
  • Spring Integration/Spring Cloud Stream으로 메시징을 처리하는 시스템
  • Spring Data로 여러 데이터소스를 통합 관리하는 시스템

5.3 대규모 트랜잭션 처리와 복잡한 ORM이 필요한 경우#

RDB를 주 Datasource로 사용할 때 Go는 유용한가?

결론부터 말하면: 단순한 CRUD라면 Go도 충분하지만, 복잡한 ORM 기능이 필요하면 Java/JPA가 낫다.

Go의 ORM 상황:

Go의 대표적인 ORM은 GORM이다. 기본적인 CRUD, 관계 매핑, 마이그레이션을 지원한다.

type User struct {
    ID        uint
    Name      string
    Orders    []Order `gorm:"foreignKey:UserID"`
}

db.Preload("Orders").Find(&users)

그러나 JPA/Hibernate와 비교하면 기능이 제한적이다.

Java JPA가 우위인 영역:

  1. Lazy Loading: JPA는 진정한 lazy loading을 지원한다. 연관 엔티티에 접근할 때 자동으로 쿼리가 실행된다. GORM의 Preload는 eager loading에 가깝다.

  2. Dirty Checking: JPA는 엔티티 변경을 자동 감지해서 트랜잭션 커밋 시 업데이트한다. GORM은 명시적으로 Save()를 호출해야 한다.

  3. 복잡한 상속 매핑: JPA의 @Inheritance 전략(SINGLE_TABLE, JOINED, TABLE_PER_CLASS)을 GORM으로 구현하기 어렵다.

  4. 2차 캐시: Hibernate의 2차 캐시(EhCache, Redis)는 성숙하고 검증되어 있다. GORM에는 공식적인 2차 캐시가 없다.

  5. Criteria API / QueryDSL: 타입 안전한 동적 쿼리 빌딩이 Java 쪽이 훨씬 강력하다.

Go가 RDB와 함께 적합한 경우:

  • 단순한 CRUD 중심의 마이크로서비스
  • 쿼리가 대부분 직접 작성하는(raw SQL) 경우
  • sqlx처럼 SQL을 직접 다루는 것을 선호하는 경우
// sqlx: SQL을 직접 작성하되 struct 매핑은 자동화
var users []User
db.Select(&users, "SELECT * FROM users WHERE status = ?", "active")

Go가 RDB와 함께 적합하지 않은 경우:

  • 복잡한 엔티티 관계와 상속 구조
  • 복잡한 트랜잭션 경계 관리
  • 대규모 배치 처리 (flush/clear 관리)
  • 동적 쿼리 빌딩이 많은 경우

5.4 팀의 학습 곡선과 기존 자산#

팀 역량을 고려해야 하는 이유:

Java 10년차 개발자 5명으로 구성된 팀이 있다고 하자. Go로 전환하면:

  • 언어 학습: 2~4주 (기본 문법)
  • 관용적 Go 코드 작성: 2~3개월
  • Go 생태계 파악 및 라이브러리 선정: 1~2개월
  • 프로덕션 레벨의 안정성 확보: 6개월~1년

이 기간 동안 생산성이 떨어진다. 기존 Java로 했으면 3개월 걸릴 프로젝트가 Go로 하면 6개월 걸릴 수 있다.

기존 자산의 가치:

대부분의 조직에는 수년간 축적된 내부 라이브러리가 있다.

  • 공통 로깅/모니터링 라이브러리
  • 내부 인증/인가 모듈
  • 도메인 특화 유틸리티
  • 테스트 픽스처와 헬퍼

이것들을 Go로 다시 만드는 건 상당한 비용이다. 특히 버그가 잡히고 엣지 케이스가 처리된 mature한 코드를 처음부터 다시 작성하는 건 리스크다.

Go 전환이 적합하지 않은 상황:

  • 팀 전체가 Java/JVM 전문가로 구성된 경우
  • 내부 Java 라이브러리 자산이 많은 경우
  • 프로젝트 일정이 촉박한 경우
  • Go 경험자가 팀에 없고 채용도 어려운 경우

5.5 Monolith에서 MSA로 전환한다면?#

이 질문에 대한 답은 “상황에 따라 다르다"이다.

시나리오 1: 기존 Monolith를 점진적으로 분리하는 경우

기존 Java Monolith에서 서비스를 하나씩 떼어내는 상황이라면, 처음에는 Java로 유지하는 게 안전하다.

이유:

  • 기존 도메인 모델을 재사용할 수 있다
  • 팀이 익숙한 기술 스택이다
  • 내부 라이브러리를 공유할 수 있다
  • 점진적 전환 시 리스크가 낮다

시나리오 2: 신규 서비스를 추가하는 경우

Monolith는 그대로 두고 새 기능을 별도 서비스로 만드는 경우, Go를 고려해볼 만하다.

특히 다음 조건이 맞으면 Go가 적합하다:

  • 도메인 로직이 단순하다
  • 고성능/저지연이 요구된다
  • 컨테이너 리소스를 최소화해야 한다
  • 팀에 Go 경험자가 있다

시나리오 3: 전면 재작성(Rewrite)하는 경우

레거시 Monolith를 완전히 새로 작성하는 경우, 언어 선택은 더 자유롭다. 하지만 이 경우에도:

  • 복잡한 도메인 로직이 많으면 Java가 안전하다
  • 단순한 서비스들의 집합이면 Go도 좋다
  • 팀 역량이 가장 중요한 요소다

하이브리드 접근:

많은 조직이 “적재적소"전략을 쓴다.

  • Java: 복잡한 비즈니스 로직, 배치 처리, 엔터프라이즈 통합
  • Go: API Gateway, 사이드카, 고성능 서비스, CLI 도구, 인프라 컴포넌트

예를 들어 Uber는 Java(서비스 로직)와 Go(인프라, 고성능 컴포넌트)를 함께 사용한다.


6. 결론: 마이그레이션 결정 체크리스트#

Go로 전환을 고려할 때 다음 질문들을 던져보자.

Go를 선택해야 하는 신호#

✅ 동시 연결 수만~수십만 개 처리가 필요한가?
✅ 서비스 시작 시간이 중요한가? (서버리스, 빈번한 스케일링)
✅ 컨테이너 메모리를 256MB 이하로 유지해야 하는가?
✅ 단일 바이너리 배포가 중요한가? (CLI, 에이전트)
✅ 비즈니스 로직이 단순한 CRUD 중심인가?
✅ 팀에 Go 경험자가 있거나 학습 의지가 강한가?

Java를 유지해야 하는 신호#

✅ 복잡한 도메인 모델과 비즈니스 로직이 있는가?
✅ Spring 생태계(Security, Batch, Integration 등)에 의존하는가?
✅ 복잡한 ORM 기능(Lazy Loading, 2차 캐시, 상속 매핑)이 필요한가?
✅ 대규모 트랜잭션과 배치 처리가 핵심인가?
✅ 팀이 Java 전문가들로 구성되어 있는가?
✅ 내부 Java 라이브러리 자산이 많은가?

어느 쪽도 압도적이지 않다면, 기존 기술 스택을 유지하는 것이 보통 안전한 선택이다. 새 언어 도입은 그 자체로 비용이기 때문이다.