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

Part 1에서 Go의 철학과 언제 Go를 선택해야 하는지 살펴봤다. 이제 실제 코드 레벨에서 Java와 Go가 어떻게 다른지 1:1로 비교해보자. Java 문법에 익숙한 개발자가 Go 코드를 읽고 쓸 수 있도록 핵심 차이점을 정리한다.


1. 변수 선언과 타입 시스템#

기본 변수 선언#

Java:

String name = "Alice";
int age = 30;
final double PI = 3.14159;

Go:

var name string = "Alice"
var age int = 30
const PI = 3.14159

Go에서는 타입이 변수명 뒤에 온다. 처음엔 어색하지만, 복잡한 타입 선언에서는 오히려 읽기 쉽다.

짧은 선언 (Short Declaration)#

Go의 := 연산자는 선언과 초기화를 동시에 한다. 타입 추론이 적용된다.

name := "Alice"      // var name string = "Alice" 와 동일
age := 30            // var age int = 30 와 동일
ratio := 0.5         // float64로 추론

:=는 함수 내부에서만 사용 가능하다. 패키지 레벨 변수는 var를 써야 한다.

package main

var globalVar = "I'm global"  // OK
// globalVar := "error"       // 컴파일 에러

func main() {
    localVar := "I'm local"   // OK
}

Zero Value#

Java에서 초기화하지 않은 인스턴스 변수는 기본값을 가진다. Go는 이를 “zero value"라 부르며 모든 변수에 적용된다.

Go 타입 Zero Value Java 대응
int, float64 0 동일
bool false 동일
string "" (빈 문자열) null (차이!)
포인터, slice, map, channel nil null
struct 모든 필드가 zero value -

중요한 차이: Go의 string은 절대 nil이 아니다. 초기화하지 않으면 빈 문자열이다.

var s string
fmt.Println(s == "")   // true
fmt.Println(len(s))    // 0

타입 변환#

Java는 암묵적 타입 변환(widening)을 허용하지만, Go는 모든 타입 변환이 명시적이다.

Java:

int i = 42;
long l = i;        // 암묵적 변환 OK
double d = i;      // 암묵적 변환 OK

Go:

var i int = 42
var l int64 = i         // 컴파일 에러!
var l int64 = int64(i)  // 명시적 변환 필요

var f float64 = float64(i)  // 명시적 변환 필요

2. 함수와 메서드#

함수 선언#

Java:

public int add(int a, int b) {
    return a + b;
}

Go:

func add(a int, b int) int {
    return a + b
}

// 같은 타입 파라미터는 축약 가능
func add(a, b int) int {
    return a + b
}

접근 제어자(public, private)가 없다. 대신 첫 글자 대소문자로 가시성을 결정한다 (뒤에서 설명).

다중 반환값 (Multiple Return Values)#

Go 함수는 여러 값을 반환할 수 있다. 이는 에러 처리의 핵심이다.

Java:

// 방법 1: 예외 던지기
public User findUser(String id) throws UserNotFoundException {
    // ...
}

// 방법 2: Optional 사용
public Optional<User> findUser(String id) {
    // ...
}

// 방법 3: null 반환 (안티패턴)
public User findUser(String id) {
    return null;  // 사용자 없으면
}

Go:

func findUser(id string) (User, error) {
    user, err := db.Query(id)
    if err != nil {
        return User{}, fmt.Errorf("user not found: %w", err)
    }
    return user, nil
}

// 호출 측
user, err := findUser("123")
if err != nil {
    log.Fatal(err)
}

반환값 중 일부를 무시하려면 _ (blank identifier)를 사용한다.

user, _ := findUser("123")  // 에러 무시 (권장하지 않음)

Named Return Values#

반환값에 이름을 붙일 수 있다. 함수 시작 시 zero value로 초기화된다.

func divide(a, b float64) (result float64, err error) {
    if b == 0 {
        err = errors.New("division by zero")
        return  // result=0, err=에러 반환
    }
    result = a / b
    return  // result=계산값, err=nil 반환
}

짧은 함수에서는 유용하지만, 긴 함수에서는 가독성을 해칠 수 있다. 팀 컨벤션에 따라 사용하자.

가변 인자 (Variadic Functions)#

Java:

public int sum(int... numbers) {
    int total = 0;
    for (int n : numbers) {
        total += n;
    }
    return total;
}

Go:

func sum(numbers ...int) int {
    total := 0
    for _, n := range numbers {
        total += n
    }
    return total
}

// 호출
sum(1, 2, 3)
sum(1, 2, 3, 4, 5)

// 슬라이스 전달 시 ... 필요
nums := []int{1, 2, 3}
sum(nums...)

3. Class → Struct + Methods#

Go에는 클래스가 없다. 대신 struct와 메서드의 조합으로 비슷한 효과를 낸다.

기본 구조#

Java:

public class User {
    private String name;
    private int age;
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String getName() {
        return name;
    }
    
    public void setName(String name) {
        this.name = name;
    }
    
    public String greet() {
        return "Hello, I'm " + name;
    }
}

Go:

type User struct {
    Name string  // 대문자: exported (public)
    Age  int
}

// 메서드는 struct 밖에서 정의
func (u User) Greet() string {
    return "Hello, I'm " + u.Name
}

// 사용
user := User{Name: "Alice", Age: 30}
fmt.Println(user.Greet())

생성자 패턴: NewXxx 컨벤션#

Go에는 생성자 문법이 없다. 대신 NewXxx 함수를 만드는 것이 관례다.

type User struct {
    name string  // 소문자: unexported (private)
    age  int
}

// "생성자" 함수
func NewUser(name string, age int) *User {
    if age < 0 {
        age = 0
    }
    return &User{
        name: name,
        age:  age,
    }
}

// 사용
user := NewUser("Alice", 30)

유효성 검사가 필요하면 에러도 함께 반환한다.

func NewUser(name string, age int) (*User, error) {
    if name == "" {
        return nil, errors.New("name cannot be empty")
    }
    if age < 0 {
        return nil, errors.New("age cannot be negative")
    }
    return &User{name: name, age: age}, nil
}

Getter/Setter가 필요 없는 이유#

Go 커뮤니티는 불필요한 getter/setter를 권장하지 않는다.

Java 스타일 (Go에서 안티패턴):

// 이렇게 하지 마세요
func (u *User) GetName() string {
    return u.name
}

func (u *User) SetName(name string) {
    u.name = name
}

Go 스타일:

type User struct {
    Name string  // 직접 접근 허용
    Age  int
}

user := User{Name: "Alice"}
user.Name = "Bob"  // 직접 수정

단, 유효성 검사나 부수 효과가 필요한 경우에는 메서드를 만든다.

type Account struct {
    balance int
}

// 잔액 변경에는 검증이 필요하므로 메서드 제공
func (a *Account) Deposit(amount int) error {
    if amount <= 0 {
        return errors.New("amount must be positive")
    }
    a.balance += amount
    return nil
}

func (a *Account) Balance() int {
    return a.balance
}

Go의 getter 네이밍 컨벤션: GetXxx()가 아닌 Xxx()를 사용한다.

값 리시버 vs 포인터 리시버#

메서드의 리시버는 값 또는 포인터가 될 수 있다.

// 값 리시버: struct의 복사본에서 동작
func (u User) Greet() string {
    return "Hello, " + u.Name
}

// 포인터 리시버: struct 원본을 수정 가능
func (u *User) SetName(name string) {
    u.Name = name  // 원본 수정
}

언제 포인터 리시버를 쓰는가:

  1. struct를 수정해야 할 때
  2. struct가 클 때 (복사 비용 절약)
  3. 일관성을 위해 (한 메서드가 포인터면 다른 것도 포인터로)

일반적인 규칙: 하나라도 포인터 리시버가 필요하면, 모든 메서드를 포인터 리시버로 통일한다.


4. Interface: 암묵적 구현#

Java vs Go 인터페이스#

Java:

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

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

Go:

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

type FileWriter struct {
    // ...
}

// implements 선언 없이, 메서드만 구현하면 됨
func (fw *FileWriter) Write(data []byte) (int, error) {
    // ...
    return len(data), nil
}

Go에서는 FileWriterWrite 메서드를 가지고 있으므로 자동으로 Writer 인터페이스를 구현한다. 이를 “덕 타이핑"이라 부른다.

덕 타이핑의 장단점#

장점:

  1. 낮은 결합도: 인터페이스를 정의한 패키지를 import할 필요가 없다.
// 패키지 A에서 정의한 인터페이스
package a

type Stringer interface {
    String() string
}

// 패키지 B: 패키지 A를 import하지 않아도 됨
package b

type MyType struct {
    Value int
}

func (m MyType) String() string {
    return fmt.Sprintf("MyType: %d", m.Value)
}
  1. 작은 인터페이스 선호: Go는 1-2개 메서드를 가진 작은 인터페이스를 권장한다.
// 표준 라이브러리의 대표적인 인터페이스들
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

// 인터페이스 조합
type ReadWriter interface {
    Reader
    Writer
}

단점:

  1. 명시성 부족: 어떤 struct가 어떤 interface를 구현하는지 코드만 봐서는 알기 어렵다.

  2. 컴파일 타임 검증의 어려움: 인터페이스 구현 여부를 컴파일러가 자동 체크하지 않을 수 있다.

// 컴파일 타임에 인터페이스 구현을 강제하는 트릭
var _ Writer = (*FileWriter)(nil)
// FileWriter가 Writer를 구현하지 않으면 컴파일 에러

Java 인터페이스와의 결정적 차이#

1. 인터페이스 정의 위치

Java: 인터페이스는 구현체보다 먼저, 보통 별도 파일에 정의한다. Go: 인터페이스는 사용하는 쪽에서 정의하는 것이 관례다.

// repository 패키지 (구현체)
package repository

type UserRepository struct{}

func (r *UserRepository) FindByID(id string) (*User, error) { ... }
func (r *UserRepository) Save(user *User) error { ... }

// service 패키지 (사용처): 필요한 메서드만 인터페이스로 정의
package service

type UserFinder interface {
    FindByID(id string) (*User, error)
}

type UserService struct {
    finder UserFinder  // repository.UserRepository를 받을 수 있음
}

2. 빈 인터페이스 (Empty Interface)

Java의 Object처럼, Go의 interface{}(또는 Go 1.18+의 any)는 모든 타입을 받을 수 있다.

func printAnything(v interface{}) {
    fmt.Println(v)
}

// 또는 Go 1.18+
func printAnything(v any) {
    fmt.Println(v)
}

printAnything(42)
printAnything("hello")
printAnything(User{Name: "Alice"})

타입 단언(type assertion)으로 원래 타입을 복원한다.

func process(v interface{}) {
    // 타입 단언
    if s, ok := v.(string); ok {
        fmt.Println("String:", s)
        return
    }
    
    // 타입 스위치
    switch val := v.(type) {
    case int:
        fmt.Println("Int:", val)
    case string:
        fmt.Println("String:", val)
    default:
        fmt.Println("Unknown type")
    }
}

5. 제어문 차이#

if문: 초기화 구문#

Go의 if는 조건 앞에 초기화 구문을 넣을 수 있다.

Java:

User user = findUser(id);
if (user != null) {
    // user 사용
}
// user가 여전히 스코프에 있음

Go:

if user, err := findUser(id); err == nil {
    // user 사용
}
// user와 err는 if 블록 밖에서 접근 불가

// 에러 처리 패턴에서 많이 사용
if err := doSomething(); err != nil {
    return err
}

switch문: 훨씬 유연함#

Go의 switch는 Java보다 강력하다.

1. break 불필요 (자동 break)

switch day {
case "Monday":
    fmt.Println("Start of week")
    // 자동으로 break
case "Friday":
    fmt.Println("End of week")
default:
    fmt.Println("Midweek")
}

다음 case로 넘어가려면 fallthrough를 명시한다.

switch n {
case 1:
    fmt.Println("One")
    fallthrough
case 2:
    fmt.Println("One or Two")
}

2. 여러 값 매칭

switch day {
case "Saturday", "Sunday":
    fmt.Println("Weekend")
default:
    fmt.Println("Weekday")
}

3. 조건 없는 switch (if-else 체인 대체)

switch {
case score >= 90:
    grade = "A"
case score >= 80:
    grade = "B"
case score >= 70:
    grade = "C"
default:
    grade = "F"
}

4. 타입 switch

func describe(v interface{}) {
    switch t := v.(type) {
    case int:
        fmt.Printf("Integer: %d\n", t)
    case string:
        fmt.Printf("String: %s\n", t)
    case bool:
        fmt.Printf("Boolean: %t\n", t)
    default:
        fmt.Printf("Unknown: %T\n", t)
    }
}

for문: Go의 유일한 반복문#

Go에는 while이 없다. for가 모든 반복을 담당한다.

전통적인 for:

for i := 0; i < 10; i++ {
    fmt.Println(i)
}

while처럼 사용:

for count > 0 {
    count--
}

무한 루프:

for {
    // break로 탈출
}

range: 컬렉션 순회

// 슬라이스
nums := []int{1, 2, 3}
for index, value := range nums {
    fmt.Printf("[%d]: %d\n", index, value)
}

// 인덱스만 필요할 때
for i := range nums {
    fmt.Println(i)
}

// 값만 필요할 때
for _, v := range nums {
    fmt.Println(v)
}

// 맵
m := map[string]int{"a": 1, "b": 2}
for key, value := range m {
    fmt.Printf("%s: %d\n", key, value)
}

// 문자열 (rune 단위)
for i, r := range "Hello, 世界" {
    fmt.Printf("%d: %c\n", i, r)
}

6. 가시성: 대문자/소문자 컨벤션#

Go의 가시성 규칙은 극도로 단순하다: 첫 글자가 대문자면 exported(public), 소문자면 unexported(package-private).

Java vs Go 비교#

Java:

public class User {
    private String name;       // private
    String nickname;           // package-private
    protected int age;         // protected
    public String email;       // public
}

Go:

type User struct {
    name     string  // unexported (같은 패키지에서만 접근)
    Nickname string  // exported (어디서든 접근 가능)
    age      int     // unexported
    Email    string  // exported
}

protected가 없다: Go에는 상속이 없으므로 protected 개념 자체가 필요 없다.

패키지 레벨 적용#

함수, 상수, 변수, 타입 모두 같은 규칙이 적용된다.

package mypackage

// 외부에서 접근 가능
const MaxSize = 100
var GlobalConfig Config
type User struct { ... }
func NewUser() *User { ... }

// 외부에서 접근 불가
const defaultTimeout = 30
var internalState state
type helper struct { ... }
func validate() bool { ... }

internal 패키지#

특정 패키지 트리 내에서만 사용할 수 있도록 internal 디렉토리를 사용한다.

myapp/
├── cmd/
│   └── server/
│       └── main.go
├── internal/           # myapp 내에서만 import 가능
│   └── auth/
│       └── auth.go
└── pkg/                # 외부에서도 import 가능
    └── api/
        └── api.go

myapp/internal/authmyapp 모듈 내부에서만 import할 수 있다. 외부 프로젝트가 import하면 컴파일 에러가 발생한다.


7. 기타 중요한 문법 차이#

세미콜론 없음#

Go는 세미콜론을 자동 삽입한다. 따라서 코드에 세미콜론을 쓰지 않는다.

// 한 줄에 여러 구문은 세미콜론 필요
x := 1; y := 2

// 보통은 쓰지 않음
x := 1
y := 2

중괄호 위치 강제#

Go의 렉서가 세미콜론을 자동 삽입하기 때문에, 중괄호 위치가 강제된다.

// 올바름
func main() {
}

// 컴파일 에러! (세미콜론이 func main() 뒤에 삽입됨)
func main()
{
}

사용하지 않는 변수/import는 에러#

Go는 사용하지 않는 변수나 import를 컴파일 에러로 처리한다.

import "fmt"  // fmt를 사용하지 않으면 에러

func main() {
    x := 1    // x를 사용하지 않으면 에러
}

개발 중에는 _로 임시 회피할 수 있다.

import _ "fmt"  // 사이드 이펙트용 import

func main() {
    x := 1
    _ = x  // 임시로 에러 회피
}

포인터#

Go에는 포인터가 있지만, C와 달리 포인터 연산이 없다.

x := 10
p := &x      // x의 주소
fmt.Println(*p)  // 역참조: 10

*p = 20      // 역참조를 통한 값 변경
fmt.Println(x)   // 20

Java의 참조와 비슷하지만, 명시적으로 *&를 사용한다.


요약: Java → Go 변환 치트시트#

Java Go
String s = "hello"; s := "hello"
final int X = 10; const X = 10
public / private 대문자 / 소문자
class Foo { } type Foo struct { }
new Foo() Foo{} 또는 &Foo{}
foo.getBar() foo.Bar (직접 접근)
implements Interface (암묵적, 메서드만 구현)
extends Parent 임베딩 Parent
try-catch if err != nil
null nil
Object interface{} 또는 any
@Override (없음)
for (Type x : list) for _, x := range list
while (cond) for cond