데이터를 담는 클래스는 다르게 취급됩니다
실무 코드의 상당수는 행위가 거의 없고 값만 담는 타입입니다. 이런 타입에서 매번 손으로 써야 했던 생성자, 접근자, 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에서도 정적 유틸리티로 충분한 경우가 많습니다.
반대로 클래스가 값을 하는 자리는 서로 관련된 상태와 그 상태를 다루는 규칙을 한곳에 묶어 잘못된 상태를 만들 수 없게 할 때입니다. 예를 들어 시작 시각과 종료 시각을 각각 변수로 들고 다니면 순서가 뒤집힌 조합이 언제든 생길 수 있지만, 생성자에서 순서를 검증하는 타입으로 묶으면 그 이후의 모든 코드는 유효한 값만 다룹니다. 캡슐화의 실질적인 이득은 정보 은닉 자체가 아니라 이 불변식 보장에 있습니다.