🚀 요약

SUMMARY

Deck을 Go로 시작하면서, Java를 먼저 익힌 입장에서 Go의 차이를 빨리 잡으려고 정리한 노트다. 새 언어는 문법을 처음부터 외우기보다 “Java의 이 개념이 Go에선 어디로 갔나”를 맞추면 습득이 빠르다. 문법 간결성, JVM 없는 단일 바이너리, 값과 포인터(다 참조가 아님), 암묵 인터페이스·늦은 제네릭, slice·map과 없는 것들(Stream·Set), for 하나로 끝나는 반복, 예외 대신 에러값, 주석·태그로 끝나는 문서·스키마, goroutine 병렬, 배터리 포함 툴체인 — 이 순서로 대응을 맞춰봤다.

클래스는 struct+인터페이스로, 예외는 에러값으로, 스레드풀은 goroutine으로, JAR은 단일 바이너리로. 이렇게 대응표를 만드니 며칠 만에 코드가 읽혔다.

1. 간결성: 외울 게 적다

Go를 처음 열고 제일 먼저 느낀 건 “작다”였다. 키워드가 25개뿐이고 문법이 한 가지 방식으로 수렴한다.

  • 포맷이 하나다. gofmt가 들여쓰기·정렬을 강제해서, Java의 코드 스타일 논쟁(중괄호 위치, 탭 대 스페이스)이 아예 없다. 포맷은 도구가 정하고 사람은 안 다툰다.
  • 공개/비공개가 대소문자다. public/private 키워드가 없다. 식별자가 대문자로 시작하면 공개, 소문자면 패키지 내부용이다. 접근제어자를 붙일 자리가 통째로 사라진다.
  • 타입 추론(x := 3)이 있어 지역 변수는 타입을 거의 안 적는다.

Java에서 접근제어자·애노테이션·장황한 선언으로 표현하던 것들이 규칙 한 줄로 대체되거나 사라진다. 처음엔 “이걸로 되나” 싶다가, 외울 게 적어 금세 코드를 읽게 됐다.

2. JVM이 없다: 단일 정적 바이너리

Java 하던 사람에게 제일 실감 나는 차이다. Go는 go build 한 번이면 런타임이 필요 없는 단일 실행 파일이 나온다. JVM도, JRE 설치도, classpath도, JAR 묶음도 없다.

  • 배포가 파일 하나. 대상 서버에 Java를 깔 필요가 없다. 바이너리를 떨구면 그냥 돈다. Deck을 Go로 정한 결정적 이유가 이거였다 — 폐쇄망에 JVM을 반입·유지하는 부담 없이 바이너리 하나만 넣으면 된다.
  • 크로스컴파일이 공짜다. GOOS/GOARCH만 바꾸면 리눅스에서 macOS·ARM 바이너리를 뽑는다. Java의 “한 번 빌드해 어디서나(JVM 위에서)“와 달리, Go는 플랫폼별 네이티브 바이너리를 아예 미리 다 만들어둔다.
  • 의존성은 go.mod 하나. Maven/Gradle 같은 별도 빌드 도구가 아니라 go 명령에 내장돼 있고, 컴파일이 빠르다.

“빌드 = JAR + JVM”이 머릿속에 박혀 있었는데, “빌드 = 그 자체로 실행되는 파일”로 바꾸는 게 첫 관문이었다.

3. 값과 포인터: “다 참조”가 아니다

Java에선 객체는 전부 참조, 원시 타입만 값이었다. Go는 struct가 기본적으로 값이라, 대입하거나 함수에 넘기면 복사된다. 원본을 고치려면 포인터(*T, &x)를 명시해야 한다.

func rename(u User)  { u.Name = "x" }   // 복사본만 바뀜 (원본 그대로)
func rename(u *User) { u.Name = "x" }   // 포인터로 넘겨야 원본이 바뀜

메서드도 값 리시버(func (u User))와 포인터 리시버(func (u *User))로 갈린다. Java에선 신경 쓸 일 없던 “이건 복사되나 공유되나”를 매번 의식하게 되는데, 익으면 오히려 변경 범위가 코드에 드러나 좋다.

여기에 nil과 zero value가 붙는다. Go는 모든 타입에 쓸 수 있는 영값(zero value)이 있다 — 숫자 0, 문자열 "", 불리언 false, 포인터·slice·map은 nil. 선언만 해도 쓸 만한 초기값이라, Java의 null 초기화와 NPE 지뢰가 줄어든다. Optional도 없다. “있을 수도 없을 수도” 있는 값은 value, ok := m[key]나 다중 반환으로 표현한다.

4. 타입 시스템: 암묵 인터페이스, 늦은 제네릭

  • 클래스도 상속도 없다. class/extends 대신 struct에 메서드를 붙이고, 재사용은 상속이 아니라 조합(embedding)으로 한다.
  • 인터페이스는 암묵적으로 만족한다. Java처럼 implements를 선언하지 않는다. 메서드 시그니처만 맞으면 그 인터페이스가 된다. 그래서 Go는 “작은 인터페이스를 받고 struct를 반환하라”는 관용이 강하다. 소비하는 쪽에서 필요한 만큼만 인터페이스를 정의한다.
  • 제네릭은 늦게(1.18) 왔다. 오래 없다가 타입 파라미터가 들어왔다. Java 제네릭(타입 소거)에 익숙하면 문법만 다를 뿐 개념은 비슷하지만, Go 코드는 여전히 인터페이스와 구체 타입으로 푸는 경우가 더 많다.

5. 자료구조: slice·map, 그리고 없는 것들

Java의 풍부한 컬렉션(List/Map/Set/Queue)과 스트림에 익숙하면, Go의 단출함이 처음엔 허전하다.

  • 언어 내장은 배열, slice(가변 길이 배열), map이 사실상 전부다. ArrayList↔slice, HashMap↔map로 대응된다.
  • Set이 없다. map[T]struct{}(값 없는 맵)로 흉내 낸다.
  • Stream API가 없다. filter/map/collect 대신 그냥 for를 쓴다. 처음엔 답답한데, 데이터 흐름이 눈에 보여 디버깅은 편하다. 정렬 등은 표준 sort/slices 패키지로 한다.

컬렉션 프레임워크의 다양한 자료구조를 골라 쓰던 습관을, “slice와 map으로 웬만한 건 다 한다”로 바꾸는 과정이었다.

6. 반복문은 for 하나

Java는 반복만 해도 for, while, do-while, 향상된 for, Iterator, 스트림까지 갈래가 많다. Go는 반복 키워드가 for 하나다.

for i := 0; i < n; i++ { }      // 전통적 for
for cond { }                     // while 대용
for { }                          // 무한 루프
for i, v := range items { }      // for-each 대용 (인덱스, 값)

range가 Java의 향상된 for와 Iterator를 겸해서 slice·map·채널·문자열을 다 돈다. 커스텀 순회도 요즘 Go(1.23+)는 함수를 넘겨 range로 도는 이터레이터를 지원한다. 반복의 표현 수단이 하나로 좁혀지니 “이 경우엔 while이 나은가” 같은 선택 자체가 없다.

7. 예외 대신 에러를 값으로

제일 크게 부딪히는 지점이다. Go에는 checked exception도, try/catch도 없다. 실패는 예외를 던지는 게 아니라 반환값으로 돌려준다. 함수가 결과와 에러를 같이 반환하고((T, error)), 호출부가 매번 확인한다.

f, err := os.Open(path)
if err != nil {
    return fmt.Errorf("open config: %w", err)   // 컨텍스트를 덧대 다시 반환
}
  • Java는 예외가 콜스택을 알아서 타고 올라간다. Go는 각 단계에서 손으로 올려보낸다. 장황하지만 에러가 어디서 나고 어디로 흐르는지가 코드에 그대로 보인다.
  • 예외 계층(catch (IOException e)) 대신 errors.Is(특정 센티넬 에러인가)와 errors.As(특정 타입인가)로 분기한다.
  • %w로 감싸면 원인 체인이 유지돼, Java의 caused by에 해당하는 맥락을 얻는다.
  • panicRuntimeException처럼 보이지만 용도가 다르다. 정상 실패엔 안 쓰고, “일어나면 안 되는 상태”에만 쓴다.

Deck에서도 정상 실패는 전부 에러 반환, 메시지는 "open config: %w"처럼 짧게, 프로세스 종료(os.Exit)는 main에서만 하고 나머지는 run류 함수가 에러를 돌려주게 뒀다. 처음엔 if err != nil이 지겨웠는데, 익으니 “이 함수가 뭘 실패할 수 있는지”가 시그니처에 드러나는 게 편했다.

8. 문서와 스키마: 주석과 태그로 끝난다

Java에선 문서는 Javadoc, 직렬화 스키마는 애노테이션(@JsonProperty 등)으로 표현했다. Go는 둘 다 더 가볍다.

  • 문서 = 주석 그 자체. 공개 식별자 위 주석이 곧 문서다. @param 같은 태그 없이 평문으로 쓰면 go doc과 pkg.go.dev가 그대로 렌더한다.
  • 스키마 = struct 태그. 필드 뒤 백틱 태그로 직렬화 규칙을 선언한다. Jackson 애노테이션에 대응하지만 한 줄이다.
type Config struct {
    Root    string `json:"root" yaml:"root"`
    DryRun  bool   `json:"dry_run" yaml:"dry_run"`
}

이 struct 하나가 JSON·YAML (역)직렬화 스키마이자, 주석을 달면 그대로 문서가 된다. Deck은 여기서 더 나가 Cobra 명령 정의에서 CLI 레퍼런스 문서를 통째로 생성한다. “코드가 곧 스키마이자 문서”라는 감각이 애노테이션+Javadoc 조합보다 손이 덜 갔다.

9. 병렬처리: goroutine과 채널

Java의 동시성은 Thread, ExecutorService, Future/CompletableFuture, synchronized로 층이 두껍다. Go는 이걸 두 원시로 좁힌다 — goroutine과 채널.

ch := make(chan int)
go worker(ch)          // goroutine 시작: 함수 앞에 go 만 붙인다
result := <-ch         // 채널로 결과를 받는다 (동기화가 채널에 내장)
  • goroutine은 OS 스레드가 아니라 런타임이 스케줄링하는 경량 실행 단위다. 스택이 작아(수 KB) 수천 개를 띄워도 부담이 적다. Java 스레드를 풀로 아껴 쓰던 것과 대조된다. (Java도 21의 가상 스레드로 이 지점에 가까워졌다.)
  • 공유 상태에 락을 거는 대신 “메모리를 공유해 통신하지 말고, 통신으로 메모리를 공유하라”가 기본 철학이다. 값을 채널로 주고받으면 그 자체가 동기화라 synchronized가 필요한 상황이 준다.
  • 여러 채널을 기다릴 땐 select, 취소·타임아웃은 context로 흘려보낸다. 상태 공유가 나을 땐 sync.Mutex/WaitGroup도 있다.

가볍다고 아무 데나 뿌리면 안 된다. Deck에선 주인과 종료 경로가 없는 goroutine을 프로덕션 코드에 띄우지 않기로, 채널 버퍼는 기본 0(또는 1)으로 두기로 정했다. 가벼운 만큼 규칙으로 눌러둔 셈이다.

10. 툴체인과 테스트: 배터리 포함

Java에선 빌드(Maven/Gradle), 테스트(JUnit), 목(Mockito), 포맷터가 다 별도 생태계였다. Go는 이걸 표준 툴체인에 넣어놨다.

  • go fmt(포맷), go vet(정적 분석), go test(테스트), go doc(문서)이 전부 기본 명령이다.
  • 테스트는 표준 testing 패키지 하나로, 테이블 주도 테스트(입력·기대값 표를 돌리는 방식)가 관용이다. 별도 프레임워크가 거의 필요 없다.
  • 레이스 디텍터(go test -race)가 내장이다. 동시성 버그를 실행 중에 잡아준다 — 위 9절의 goroutine을 마음 편히 쓰게 해준 장치.
  • 프로파일러(pprof)도 표준이다.

“도구를 고르고 붙이는” 단계가 거의 없어서, 언어를 배우는 것과 생태계를 세팅하는 것이 분리돼 있지 않았다. 이것도 습득 속도를 붙여준 요소였다.

남은 것

Java에서 그리운 것도 있다. Spring 같은 성숙한 프레임워크 생태계, 예외가 스택트레이스를 알아서 남겨주던 편함이 대표적이다. 반대로 Go는 문법이 작고 방식이 하나로 수렴해서, 팀이 커져도 남이 쓴 코드가 내 것과 비슷하게 읽힌다. 결국 빠른 습득의 요령은 하나였다 — 새 문법을 백지에서 외우지 말고, 아는 Java 개념이 Go에서 어디로 대응되는지부터 맞출 것.

🔗 참고