Java to Go: #2. 문법 전환 가이드: Java 코드를 Go로 옮기기
이 글은 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 // 원본 수정
}
언제 포인터 리시버를 쓰는가:
- struct를 수정해야 할 때
- struct가 클 때 (복사 비용 절약)
- 일관성을 위해 (한 메서드가 포인터면 다른 것도 포인터로)
일반적인 규칙: 하나라도 포인터 리시버가 필요하면, 모든 메서드를 포인터 리시버로 통일한다.
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에서는 FileWriter가 Write 메서드를 가지고 있으므로 자동으로 Writer 인터페이스를 구현한다. 이를 “덕 타이핑"이라 부른다.
덕 타이핑의 장단점#
장점:
- 낮은 결합도: 인터페이스를 정의한 패키지를 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)
}
- 작은 인터페이스 선호: 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
}
단점:
-
명시성 부족: 어떤 struct가 어떤 interface를 구현하는지 코드만 봐서는 알기 어렵다.
-
컴파일 타임 검증의 어려움: 인터페이스 구현 여부를 컴파일러가 자동 체크하지 않을 수 있다.
// 컴파일 타임에 인터페이스 구현을 강제하는 트릭
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/auth는 myapp 모듈 내부에서만 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 |