
오늘은 데이터 클래스와 오브젝트에 대해서 배웠다. 데이터 클래스가 생소한 개념이라 좀 어려웠다.
데이터 클래스Data Class
데이터 클래스는 데이터를 저장하는 목적의 클래스로서, 데이터를 저장하고 불러오는 것에 특화되어 있다. `data` 키워드로 선언하며, 생성 시 자동으로 `equals()`, `hashCode()`, `toString()`, `copy()` 메서드를 생성한다. 그리고 프로퍼티에 대한 `componentN()` 함수 또한 생성하는데, 객체의 필드 값을 순서대로 분해하여 여러 변수에 한 번에 대입할 수 있게 해주는 구조 분해 선언(Destructuring Declaration)을 지원한다.
지원되는 `equals()`의 경우 `==`로 간단하게 키워드를 지원하며, Data Class로 만든 요소는 값이 일치한다면 `setOf`에 의해서 제거된다. `copy()`의 경우 기존 객체를 복사하면서 일부 프로퍼티만 변경 가능하게 된다.
// 1. 데이터 클래스(Data Class) 선언
// 프로퍼티 정의 시 자동으로 equals(), hashCode(), toString(), copy(), componentN() 메서드가 생성됨
data class UserDto(
val name: String,
val age: Int,
val address: String
)
fun main() {
// 2. 데이터 클래스 인스턴스 생성
val user1 = UserDto("홍길동", 30, "서울")
val user2 = UserDto("홍길동", 30, "서울") // user1과 필드 값이 동일한 별도 객체 생성
// 3. equals() 및 == 연산자 구동 검증
// 일반 클래스와 달리 주소값이 아닌 내부 필드 값을 기준으로 동등성을 비교함
if (user1 == user2) {
println("동등성 비교 결과: 두 객체의 데이터 값이 일치합니다.") // 실행 출력됨
}
// 4. setOf 컬렉션 연동 및 중복 제거 검증
// 데이터 클래스 내장 hashCode()와 equals()에 의해 값이 일치하는 user2는 중복 요소로 판정되어 자동으로 제거됨
val userSet = setOf(user1, user2)
println("setOf 결과: ${userSet.size}개의 객체만 유지됨 -> 데이터: $userSet")
// 출력 결과: 1개의 객체만 유지됨 -> 데이터: [UserDto(name=홍길동, age=30, address=서울)]
// 5. copy() 메서드를 활용한 불변성 유지 및 값 변경 복사
// 기존 객체의 데이터를 손상시키지 않고 특정 필드만 수정한 새로운 불변 객체를 생성함
val user3 = user1.copy(age = 31)
println("copy() 결과: 기존 나이=${user1.age} -> 변경된 나이=${user3.age}")
// 6. componentN() 함수 기반의 구조 분해 선언(Destructuring Declaration) 수행
// 컴파일러가 자동 생성한 component1(), component2(), component3()를 호출하여 필드 값을 순서대로 변수에 일괄 대입함
val (extractedName, extractedAge, extractedAddress) = user3
println("구조 분해 결과: 성명=$extractedName, 연령=$extractedAge, 주소=$extractedAddress")
// 7. 언더스코어(_)를 활용한 특정 프로퍼티 분해 생략 (식별자 생략 기법)
// 세 번째 필드(address)의 값이 필요하지 않은 경우, _ 기호를 배치하여 component3() 호출 및 변수 할당을 제어함
val (onlyName, onlyAge, _) = user3
println("필드 생략 결과: 성명=$onlyName, 연령=$onlyAge (주소 데이터는 컴파일 대상에서 제외)")
}
실무에서 Entity를 DTO에 담아 사용하는 경우가 많기 때문에, `toDTO`나 `toEntity`같은 클래스 및 메서드 사용이 잦았는데, Kotlin에서는 확장함수를 사용하여 이를 간단하게 표현할 수 있다.
// 1. 데이터베이스 레이어에서 사용하는 순수 자바 스타일의 JPA 엔티티 클래스 정의
class UserEntity(
var id: Long,
var name: String,
var age: Int
)
// 2. 서비스 및 표현 레이어에서 데이터 송수신 목적으로 사용하는 불변 데이터 클래스(DTO) 정의
data class UserDto(
val name: String,
val age: Int
)
// ==========================================
// 코틀린 확장 함수(Extension Function)를 활용한 매퍼 정의
// ==========================================
// 엔티티(Entity)를 데이터 전송 객체(DTO)로 변환하는 확장 함수
// 기존 UserEntity 소스코드를 수정하지 않고 외부에서 메서드를 주입하는 구조임
fun UserEntity.toDto(): UserDto {
return UserDto(
name = this.name, // 엔티티의 name 필드를 DTO에 매핑
age = this.age // 엔티티의 age 필드를 DTO에 매핑
)
}
// 데이터 전송 객(DTO)를 엔티티(Entity)로 변환하는 확장 함수
fun UserDto.toEntity(id: Long): UserEntity {
return UserEntity(
id = id,
name = this.name,
age = this.age
)
}
fun main() {
// 데이터베이스에서 조회한 가상의 엔티티 객체 생성
val entity = UserEntity(id = 1L, name = "홍길동", age = 30)
// 3. 확장 함수 호출을 통한 엔티티 -> DTO 변환 수행
// 외부 유틸 클래스를 거치지 않고 엔티티 객체 자체의 내장 메서드처럼 직관적으로 호출 가능함
val dto: UserDto = entity.toDto()
println("엔티티에서 변환된 DTO 데이터: $dto")
// 출력 결과: UserDto(name=홍길동, age=30)
// 4. 확장 함수 호출을 통한 DTO -> 엔티티 변환 수행
val newEntity: UserEntity = dto.toEntity(id = 2L)
println("DTO에서 변환된 엔티티 ID: ${newEntity.id}, 성명: ${newEntity.name}")
}
만약 위를 Java로 표현하면 이런 식이 될 것이다.
// 1. 데이터베이스 레이어에서 사용하는 순수 JPA 엔티티 클래스 정의
public class UserEntity {
private Long id;
private String name;
private int age;
// 자바에서는 필수적인 생성자 정의
public UserEntity(Long id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}
// 자바 데이터 바인딩을 위한 Getter / Setter 정의 (보일러플레이트 코드)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
}
// 2. 서비스 및 표현 레이어에서 데이터 송수신 목적으로 사용하는 불변 데이터 객체(DTO) 정의
public final class UserDto {
private final String name;
private final int age;
public UserDto(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public String toString() {
return "UserDto{" + "name='" + name + '\'' + ", age=" + age + '}';
}
}
// ==========================================
// 🚨 자바에서 객체 변환을 위해 사용하는 정적 매퍼 유틸리티 클래스 정의
// ==========================================
public class UserMapper {
// 엔티티(Entity)를 데이터 전송 객체(DTO)로 변환하는 정적 메서드
// 자바는 기존 클래스 외부에 메서드를 주입할 수 없으므로, 이처럼 외부 유틸 클래스를 설계해야 함
public static UserDto toDto(UserEntity entity) {
if (entity == null) {
return null;
}
return new UserDto(
entity.getName(),
entity.getAge()
);
}
// 데이터 전송 객체(DTO)를 엔티티(Entity)로 변환하는 정적 메서드
public static UserEntity toEntity(UserDto dto, Long id) {
if (dto == null) {
return null;
}
return new UserEntity(
id,
dto.getName(),
dto.getAge()
);
}
}
// 3. 실제 비즈니스 로직(Service 레이어)에서의 호출 및 실행 흐름
public class Main {
public static void main(String[] args) {
// 데이터베이스에서 조회한 가상의 엔티티 객체 생성
UserEntity entity = new UserEntity(1L, "홍길동", 30);
// ❌ 외부 정적 매퍼 클래스를 호출하며 파라미터로 객체를 넘겨주어야 함 (가독성 저하 및 절차지향적 구조)
UserDto dto = UserMapper.toDto(entity);
System.out.println("엔티티에서 변환된 DTO 데이터: " + dto);
// 출력 결과: UserDto{name='홍길동', age=30}
// DTO에서 엔티티로 변환을 수행하는 시점에도 동일하게 유틸 클래스 호출 구조를 유지함
UserEntity newEntity = UserMapper.toEntity(dto, 2L);
System.out.println("DTO에서 변환된 엔티티 ID: " + newEntity.getId() + ", 성명: " + newEntity.getName());
}
}
보다시피 Java는 외부 매퍼 클래스를 설계해 파라미터로 객체를 넘겨야 하는 반면, Kotlin은 확장 함수를 통해 원본 소스코드를 보존하면서도 객체 주체적인 내장 메서드 형태의 호출을 지원한다.
오브젝트
Kotlin의 오브젝트Object는 싱글톤 패턴을 언어 차원에서 지원하며, 지연 초기화 및 멀티스레드 안전성을 보장한다. 키워드는 `class`가 아니라 `object`로 사용한다. 별도의 인스턴스 생성 없이 바로 사용 가능하며, 애플리케이션 전체에서 단 하나의 인스턴스만 존재하게 된다. 익명 객체 표현식을 사용하면 일회성 객체를 간단히 생성할 수 있다.
// 1. object 키워드를 활용한 전역 싱글톤 객체 선언
object DatabaseConfig {
var url = "jdbc:mysql://localhost:3306/mydb"
init {
println("최초 접근 시점에 지연 초기화 실행")
}
}
fun main() {
// 2. 별도의 인스턴스 생성 없이 바로 사용 가능 (애플리케이션 내 단 하나만 존재)
DatabaseConfig.url = "jdbc:postgresql://localhost:5432/prod" // 👈 이 순간 최초 접근 및 init 실행
val config1 = DatabaseConfig
val config2 = DatabaseConfig
if (config1 === config2) {
println("전체에서 단 하나의 인스턴스만 존재함이 보장됨")
}
// 3. 익명 객체 표현식을 활용한 일회성 객체 즉석 생성
// Runnable 인터페이스를 익명 객체로 구현하여 run() 메서드를 호출하는 구조
val temporaryObject = object : Runnable {
override fun run() {
println("익명 객체를 활용해 일회성 로직 실행")
}
}
temporaryObject.run()
}
이 중 companion object는 클래스 내부에 정의된 싱글톤 객체로, Java의 정적 멤버(static)와 유사한 역할을 한다. 클래스 이름으로 직접 접근 가능하며, Java의 static과 비슷하지만 실제 객체로서 작용한다. 클래스당 하나만 가질 수 있으며, 클래스 내부에서 `companion object {}`로 선언할 수 있다.
class Product {
// 클래스 내부에 정의된 싱글톤 객체 (클래스당 단 하나만 선언 가능)
companion object {
val maxPrice = 100000
fun createDefault(): Product {
return Product()
}
}
}
fun main() {
// Java의 static 멤버처럼 클래스 이름으로 직접 접근하여 사용
val limit = Product.maxPrice
val defaultProduct = Product.createDefault()
}
companion object는 생성자를 private로 제한하고 인스턴스 생성을 제한하는 팩토리 메서드 패턴을 구현하는 것에 적합하다. Java의 경우는 `.of()`, `.form()`과 같은 static을 활용하여 함수를 생성하지만, Kotlin은 companion object 안에 정적 팩토리 함수를 넣어 제작한다. 코드로 비교하면 다음과 같다.
Java
public class User {
private final String name;
private final int age;
// 1. 외부 인스턴스 생성을 강제로 제한 (private 생성자)
private User(String name, int age) {
this.name = name;
this.age = age;
}
// 2. static을 활용한 정적 팩토리 메서드 제공 (.from 관례)
public static User from(String name, int age) {
return new User(name, age);
}
}
// 실제 사용 시점
User user = User.from("홍길동", 30);
Kotlin
class User private constructor(
val name: String,
val age: Int
) {
// 클래스 내부에 정의된 동반 객체에 팩토리 함수를 배치
companion object {
// static 대신 companion object 내부 함수로 구현 (.of / .from 관례 준수)
fun of(name: string, age: Int): User {
return User(name, age)
}
}
}
// 실제 사용 시점
// Java의 static 메서드 호출과 외형은 동일하나, 내부적으로는 Companion 객체의 함수가 실행됨
val user = User.of("홍길동", 30)
이렇게 Kotlin은 comanion object를 통해 순수 객체지향적인 캡슐화를 더 간단하게 달성할 수 있게 도와준다.
오늘은 좀 더 특별하고 생소한 개념에 대해서 배워봤다. 강사님께서는 `data class`를 가급적 사용하면 좋다고 하셨는데, 솔직히 정말 편해보이기는 했다. 다음에는 람다식에 대해서 배울 예정이다.
