이 글은 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 ValueJava 대응
int, float640동일
boolfalse동일
string"" (빈 문자열)null (차이!)
포인터, slice, map, channelnilnull
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 변환 치트시트#

JavaGo
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-catchif err != nil
nullnil
Objectinterface{} 또는 any
@Override(없음)
for (Type x : list)for _, x := range list
while (cond)for cond