PHpullh

LANGUAGE COMPARISON

Class를 언어별로 비교하기

같은 개념을 실제 예제로 나란히 확인하고, 각 언어의 개별 설명으로 이어갈 수 있습니다.

클래스가 없는 언어도 객체 지향을 합니다

이 비교에서 가장 눈에 띄는 것은 Go에 class 키워드가 아예 없다는 점입니다. Go는 데이터를 담는 struct와 그 타입에 붙는 메서드, 그리고 인터페이스 세 가지로 같은 문제를 풉니다. 결정적인 차이는 인터페이스 구현을 선언하지 않는다는 것입니다. 필요한 메서드를 갖고 있으면 그 인터페이스를 만족한 것으로 취급합니다. 이런 구조적 타이핑 덕분에 라이브러리 타입에 나중에 인터페이스를 맞출 수 있지만, 반대로 "이 타입이 어떤 인터페이스를 구현하는지" 코드에 적혀 있지 않아 IDE 없이 읽기는 불편합니다.

Java와 Kotlin은 반대편입니다. implements나 콜론으로 관계를 명시해야 하고, 컴파일러가 그 관계를 검사합니다. PHP도 명시적 선언 방식이며 단일 상속에 트레이트로 코드 재사용을 보완합니다. Python은 클래스와 상속을 갖고 있지만 실제 호출은 던지기 타이핑으로 이루어져서, 상속 관계가 없어도 같은 메서드만 있으면 그대로 동작합니다. 즉 선언은 명목적이고 사용은 구조적인 중간 형태입니다.

Kotlin

class & 주 생성자 & init

Kotlin 클래스의 주 생성자(primary constructor)와 초기화 블록.

// 주 생성자 — 클래스 헤더에 직접
class Person(
    val name: String,           // val/var → 프로퍼티 자동 생성
    var age: Int,
    private val email: String   // private 프로퍼티
) {
    // init 블록 — 생성 시 실행
    init {
        require(age >= 0) { "나이는 0 이상이어야 합니다" }
        println("$name 생성됨")
    }

    // 보조 생성자
    constructor(name: String) : this(name, 0, "")

    // 프로퍼티 커스텀 getter
    val isAdult: Boolean
        get() = age >= 18

    // 프로퍼티 커스텀 setter
    var nickname: String = name
        set(value) {
            field = value.trim()  // field = backing field
        }

    override fun toString() = "Person($name, $age)"
}

fun main() {
    val p = Person("Alice", 25, "alice@test.com")
    println(p.isAdult)   // true
    p.nickname = "  Dev  "
    println(p.nickname)  // "Dev"
}

Python

클래스 & __init__ & 프로퍼티

Python OOP의 핵심. __init__, @property, @classmethod, @staticmethod.

from datetime import date

class Person:
    # 클래스 변수 (모든 인스턴스 공유)
    species = "Homo sapiens"

    def __init__(self, name: str, birth_year: int):
        # 인스턴스 변수
        self.name = name
        self._birth_year = birth_year   # 관례적 protected
        self.__secret = "비밀"           # name mangling (private)

    # @property — getter
    @property
    def age(self) -> int:
        return date.today().year - self._birth_year

    # @property.setter
    @age.setter
    def age(self, value: int):
        if value < 0:
            raise ValueError("나이는 0 이상")
        self._birth_year = date.today().year - value

    # @classmethod — cls 인자, 클래스 메서드
    @classmethod
    def from_string(cls, data: str) -> "Person":
        name, year = data.split(",")
        return cls(name.strip(), int(year.strip()))

    # @staticmethod — self/cls 없음, 유틸리티
    @staticmethod
    def is_adult(age: int) -> bool:
        return age >= 18

    def __repr__(self) -> str:
        return f"Person(name={self.name!r}, age={self.age})"

p1 = Person("Alice", 1993)
p2 = Person.from_string("Bob, 1990")
print(p1.age)               # 계산된 나이
print(Person.is_adult(20))  # True
print(repr(p1))

Go

구조체 & 메서드

Go에는 클래스가 없습니다. struct와 메서드로 OOP를 구현합니다.

package main

import (
	"fmt"
	"math"
)

// 구조체 정의
type Point struct {
	X, Y float64
}

// 값 수신자 메서드 — 복사본에서 실행
func (p Point) Distance() float64 {
	return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

// 포인터 수신자 메서드 — 원본 수정 가능
func (p *Point) Scale(factor float64) {
	p.X *= factor
	p.Y *= factor
}

func (p Point) String() string {
	return fmt.Sprintf("(%.1f, %.1f)", p.X, p.Y)
}

// 임베딩 — 상속 대신
type Circle struct {
	Point        // 임베딩 (익명 필드)
	Radius float64
}

func (c Circle) Area() float64 {
	return math.Pi * c.Radius * c.Radius
}

// 구조체 태그 (JSON, DB 매핑)
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

func main() {
	p := Point{X: 3, Y: 4}
	fmt.Println(p.Distance())  // 5
	p.Scale(2)
	fmt.Println(p)             // (6.0, 8.0)

	// 임베딩으로 Point 메서드 승격
	c := Circle{Point: Point{1, 2}, Radius: 5}
	fmt.Println(c.Distance()) // 임베딩된 Point의 메서드
	fmt.Printf("넓이: %.2f
", c.Area())

	// 구조체 리터럴
	u1 := User{ID: 1, Name: "Alice"}
	u2 := User{1, "Bob", "bob@test.com"}
	_ = u1; _ = u2

	// 익명 구조체
	config := struct {
		Host string
		Port int
	}{"localhost", 8080}
	fmt.Println(config.Host, config.Port)
}

Java

익명 클래스와 람다 변환

익명 클래스의 사용법과 람다로 변환할 수 있는 조건을 알아봅니다.

import java.util.*;

public class AnonymousClass {
    interface Greeting {
        String greet(String name);
    }

    abstract static class Formatter {
        abstract String format(String text);
        String wrap(String text) { return "[" + format(text) + "]"; }
    }

    public static void main(String[] args) {
        // 익명 클래스 — SAM이면 람다로 변환 가능
        Greeting g1 = new Greeting() {
            @Override
            public String greet(String name) {
                return "안녕, " + name;
            }
        };

        // 람다로 변환
        Greeting g2 = name -> "안녕, " + name;

        // 추상 클래스 — 람다 변환 불가 (상태/여러 메서드)
        Formatter f = new Formatter() {
            @Override
            String format(String text) {
                return text.toUpperCase();
            }
        };
        System.out.println(f.wrap("hello")); // [HELLO]

        // 익명 클래스에서 this는 익명 클래스 자신
        // 람다에서 this는 바깥 클래스
        Runnable anon = new Runnable() {
            public void run() {
                System.out.println(this.getClass().getName());
            }
        };
        Runnable lambda = () -> {
            System.out.println(this.getClass().getName());
        };
    }
}

PHP

readonly 클래스

값 객체 전체를 immutable하게 만들고 싶을 때 readonly class를 사용하는 예제입니다.

<?php
readonly class Money {
    public function __construct(
        public int $amount,
        public string $currency
    ) {}
}

$money = new Money(1000, 'KRW');
echo $money->currency;

JavaScript

기본 클래스 또는 구조체

생성자 매개변수에 public을 붙여 name과 level 필드를 별도 선언 없이 만들고, summary()가 둘을 콜론으로 이어 붙인 문자열을 돌려줍니다. 데이터와 그 데이터를 다루는 동작을 한 덩어리로 묶을 때 쓰는 형태입니다. 이 매개변수 프로퍼티 문법은 TypeScript 전용 확장이라 같은 코드를 순수 JS 파일에 그대로 옮기면 동작하지 않습니다.

// 기본 클래스 또는 구조체
class User {
  constructor(name, level) { this.name = name; this.level = level; }
  summary() { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 1).summary());

TypeScript

기본 클래스 또는 구조체

생성자 매개변수에 public을 붙여 name과 level 필드를 별도 선언 없이 만들고, summary()가 둘을 콜론으로 이어 붙인 문자열을 돌려줍니다. 데이터와 그 데이터를 다루는 동작을 한 덩어리로 묶을 때 쓰는 형태입니다. 이 매개변수 프로퍼티 문법은 TypeScript 전용 확장이라 같은 코드를 순수 JS 파일에 그대로 옮기면 동작하지 않습니다.

// 기본 클래스 또는 구조체
class User {
  constructor(public name: string, public level: number) {}
  summary(): string { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 1).summary());

C#

기본 클래스 또는 구조체

생성자 매개변수에 public을 붙여 name과 level 필드를 별도 선언 없이 만들고, summary()가 둘을 콜론으로 이어 붙인 문자열을 돌려줍니다. 데이터와 그 데이터를 다루는 동작을 한 덩어리로 묶을 때 쓰는 형태입니다. 이 매개변수 프로퍼티 문법은 TypeScript 전용 확장이라 같은 코드를 순수 JS 파일에 그대로 옮기면 동작하지 않습니다.

// 기본 클래스 또는 구조체
using System;

public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 1).Name);

C++

기본 클래스 또는 구조체

생성자 매개변수에 public을 붙여 name과 level 필드를 별도 선언 없이 만들고, summary()가 둘을 콜론으로 이어 붙인 문자열을 돌려줍니다. 데이터와 그 데이터를 다루는 동작을 한 덩어리로 묶을 때 쓰는 형태입니다. 이 매개변수 프로퍼티 문법은 TypeScript 전용 확장이라 같은 코드를 순수 JS 파일에 그대로 옮기면 동작하지 않습니다.

// 기본 클래스 또는 구조체
#include <iostream>
#include <string>

struct User { std::string name; int level; };
int main() {
    User user{"Ada", 1};
    std::cout << user.name << "\n";
}

Rust

기본 클래스 또는 구조체

생성자 매개변수에 public을 붙여 name과 level 필드를 별도 선언 없이 만들고, summary()가 둘을 콜론으로 이어 붙인 문자열을 돌려줍니다. 데이터와 그 데이터를 다루는 동작을 한 덩어리로 묶을 때 쓰는 형태입니다. 이 매개변수 프로퍼티 문법은 TypeScript 전용 확장이라 같은 코드를 순수 JS 파일에 그대로 옮기면 동작하지 않습니다.

// 기본 클래스 또는 구조체
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 1 };
    println!("{}", user.name);
}

데이터를 담는 클래스는 다르게 취급됩니다

실무 코드의 상당수는 행위가 거의 없고 값만 담는 타입입니다. 이런 타입에서 매번 손으로 써야 했던 생성자, 접근자, equals, hashCode, 문자열 표현을 각 언어가 문법으로 흡수했습니다. Kotlin은 data class, Java는 record, Python은 @dataclass, PHP는 생성자 프로퍼티 승격과 readonly 프로퍼티가 그 자리에 있습니다. Go는 이 방향으로 가지 않고 평범한 구조체를 그대로 쓰되, 비교는 필드가 모두 비교 가능하면 ==로 됩니다.

여기서 조용히 동작이 갈리는 지점이 동등성입니다. Java에서 일반 클래스로 만든 두 객체는 필드가 전부 같아도 equals를 재정의하지 않으면 서로 다릅니다. Kotlin의 data class는 주 생성자에 선언된 프로퍼티만 비교에 넣기 때문에, 본문에 추가로 선언한 프로퍼티는 동등성에서 빠집니다. Python의 dataclass는 옵션에 따라 비교 메서드 생성 여부가 달라집니다. 이 타입들을 맵의 키나 집합 원소로 쓸 때 결과가 어긋나는 원인이 대부분 여기에 있습니다.

상속처럼 보이지만 상속이 아닌 것

Go의 임베딩은 특히 오해가 잦습니다. 구조체 안에 다른 타입을 이름 없이 넣으면 그 메서드가 바깥으로 승격되어 상속처럼 보이지만, 재정의와 가상 호출은 일어나지 않습니다. 임베드된 타입의 메서드가 다른 메서드를 호출하면 그 호출은 바깥에서 새로 정의한 동명 메서드가 아니라 원래 타입의 것으로 갑니다. Java나 Kotlin의 오버라이딩을 기대하고 설계하면 이 지점에서 무너집니다. 다형성이 필요하면 임베딩이 아니라 인터페이스를 써야 합니다.

  • 가시성 규칙 — Java, Kotlin, PHP는 private·protected·public을 문법으로 갖습니다. Go는 식별자 첫 글자의 대소문자로 패키지 외부 공개 여부를 정합니다. Python은 강제 장치가 없고 밑줄 접두사라는 관례만 있어, 캡슐화를 언어가 아니라 팀이 지켜야 합니다.
  • 다중 상속 — Python만 클래스 다중 상속을 허용하며 MRO 규칙으로 호출 순서를 결정합니다. 나머지는 단일 상속에 인터페이스나 트레이트로 보완합니다. 믹스인이 깊어지면 Python에서도 어느 구현이 불릴지 추적이 어려워집니다.
  • 초기화 순서 — 부모 생성자가 자식이 오버라이드한 메서드를 호출하면, 자식 필드가 아직 초기화되지 않은 상태에서 그 메서드가 실행됩니다. Java, Kotlin, PHP에 공통으로 존재하는 함정이며 Kotlin은 이 패턴을 경고합니다.
  • 동적 속성 — Python과 PHP는 정의하지 않은 속성을 런타임에 붙일 수 있습니다. 유연하지만 오타가 새 속성 생성으로 조용히 넘어갑니다. Python은 __slots__로 막을 수 있습니다.

상속을 얼마나 쓸지에 대한 판단은 언어보다 문제에 달려 있습니다. 다섯 언어 모두 "구현 상속보다 조합"을 권하는 방향으로 최근 문법이 움직였고, Go는 아예 상속을 빼는 쪽을 택했습니다. 자세한 설계 패턴은 Java 객체지향, Kotlin 객체지향, Python 객체지향, Go 구조체와 인터페이스 문서에서 이어 보실 수 있습니다.

클래스로 만들지 말아야 할 것

다섯 언어 모두에서 흔한 낭비가 상태 없는 클래스입니다. 필드가 없고 메서드가 하나뿐인데 클래스로 감싸 두면, 호출부는 객체를 만드는 절차를 거쳐야 하고 테스트는 그 객체를 주입하는 배선을 갖춰야 합니다. Python과 PHP, Kotlin과 Go에는 최상위 함수가 있으므로 그냥 함수로 두는 편이 낫고, Java에서도 정적 유틸리티로 충분한 경우가 많습니다.

반대로 클래스가 값을 하는 자리는 서로 관련된 상태와 그 상태를 다루는 규칙을 한곳에 묶어 잘못된 상태를 만들 수 없게 할 때입니다. 예를 들어 시작 시각과 종료 시각을 각각 변수로 들고 다니면 순서가 뒤집힌 조합이 언제든 생길 수 있지만, 생성자에서 순서를 검증하는 타입으로 묶으면 그 이후의 모든 코드는 유효한 값만 다룹니다. 캡슐화의 실질적인 이득은 정보 은닉 자체가 아니라 이 불변식 보장에 있습니다.