JAVA
[Java] 자바의 약한 복사(Shallow Copy)란?
Stark97
2024. 9. 21. 15:17
반응형
자바의 약한 복사를 정리해 봤다.
1. 약한 복사(Shallow Copy)란?
약한 복사란?
- 약한 복사(Shallow Copy)는 객체를 복사할 때 원본 객체의 필드 값들을 그대로 새로운 객체에 복사하는 방식이다. 여기서 중요한 점은 객체 내에 포함된 참조형 필드, 즉 다른 객체를 참조하는 필드들이 원본과 복사된 객체 모두 동일한 메모리 주소를 가리킨다는 것이다.
- 즉, 단순히 필드의 값이나 참조를 복사할 뿐, 참조된 객체 자체는 복제하지 않는다. 따라서 원본 객체와 복사된 객체는 동일한 참조형 필드를 공유하게 되며, 한쪽에서 참조된 객체를 변경하면 다른 쪽에도 그 변경이 영향을 미칠 수 있다.
- 예를 들어, 사람이 주소(Address)라는 필드를 가지고 있는 객체라고 가정해 보자. 약한 복사를 통해 사람 객체를 복사하면, 원본 사람과 복사된 사람이 모두 동일한 주소 객체를 참조하게 된다. 따라서 복사된 사람의 주소를 변경하면 원본 사람의 주소도 함께 변경된다. 이는 참조형 필드가 공유되기 때문이다.
왜 복사가 필요할까?
- 객체의 상태 보호
- 원본 객체를 변경하지 않고 그 상태를 유지하기 위해 복사본을 사용한다. 특히 원본 데이터를 보호해야 하는 경우에 유용하다.
- 원본 객체를 변경하지 않고 그 상태를 유지하기 위해 복사본을 사용한다. 특히 원본 데이터를 보호해야 하는 경우에 유용하다.
- 독립적인 작업 수행
- 복사된 객체에서 독립적으로 작업을 수행하여 원본 객체에 영향을 주지 않도록 한다. 예를 들어, 여러 스레드가 동일한 객체를 동시에 사용해야 할 때 각각의 스레드가 독립적인 복사본을 사용하면 안전하게 작업을 수행할 수 있다.
- 복사된 객체에서 독립적으로 작업을 수행하여 원본 객체에 영향을 주지 않도록 한다. 예를 들어, 여러 스레드가 동일한 객체를 동시에 사용해야 할 때 각각의 스레드가 독립적인 복사본을 사용하면 안전하게 작업을 수행할 수 있다.
- 성능 최적화
- 전체 객체를 깊게 복사하지 않고 필요한 부분만 복사하여 메모리 사용과 복사 시간을 절약할 수 있다. 대규모 객체나 복잡한 구조를 가진 객체의 경우 약한 복사가 유리할 수 있다.
- 전체 객체를 깊게 복사하지 않고 필요한 부분만 복사하여 메모리 사용과 복사 시간을 절약할 수 있다. 대규모 객체나 복잡한 구조를 가진 객체의 경우 약한 복사가 유리할 수 있다.
- 데이터 전달
- 원본 객체를 직접 전달하지 않고 복사본을 전달함으로써 데이터의 일관성과 보안을 유지할 수 있다. 이는 특히 클라이언트-서버 구조에서 중요하다.
참고. 참조형 필드란? (이해를 돕기 위한 설명)
- 참조형 필드는 객체 지향 프로그래밍에서 클래스 내에 선언된 필드 중 다른 객체를 참조하는 필드를 말한다. 이는 기본 자료형(예: int, double, char 등)과는 달리, 실제 데이터를 저장하는 대신 해당 데이터가 위치한 메모리 주소를 저장한다.
- 즉, 참조형 필드는 객체 간의 관계를 나타내거나 복잡한 데이터 구조를 구성하는 데 사용된다. 예를 들어, Person 클래스가 Address 객체를 필드로 가질 때, Address 필드는 참조형 필드가 된다.
class Address {
String city;
String street;
Address(String city, String street) {
this.city = city;
this.street = street;
}
}
class Person {
String name; // 기본 자료형 필드
Address address; // 참조형 필드
Person(String name, Address address) {
this.name = name;
this.address = address;
}
}
public class ReferenceTypeExample {
public static void main(String[] args) {
Address addr = new Address("Seoul", "Gangnam");
Person person1 = new Person("Alice", addr);
Person person2 = new Person("Bob", addr);
// person1과 person2는 동일한 Address 객체를 참조
System.out.println(person1.address == person2.address); // 출력: true
}
}
- 위 예시에서 Person 클래스의 address 필드는 Address 객체를 참조하는 참조형 필드다. person1과 person2는 동일한 Address 객체를 가리키므로, 한쪽에서 주소를 변경하면 다른 쪽에도 그 변경이 반영된다.
2. 깊은 복사(Deep Copy)와의 비교
깊은 복사란 무엇인가?
- 약한 복사와 종종 혼동되는 개념이 바로 깊은 복사(Deep Copy)다. 깊은 복사는 객체를 복사할 때 원본 객체의 모든 필드와 그 필드가 참조하는 객체들까지 모두 복사하는 방법이다. 따라서 원본 객체와 복사된 객체는 완전히 독립적인 상태가 되며, 한쪽에서의 변경이 다른 쪽에 전혀 영향을 미치지 않는다.
비교 정리
- 약한 복사(Shallow Copy)
- 원본 객체와 복사된 객체가 참조형 필드를 공유한다. 이는 메모리 사용을 절약하고 복사 속도가 빠르지만, 한쪽의 변경이 다른 쪽에 영향을 미칠 수 있는 단점이 있다.
- 원본 객체와 복사된 객체가 참조형 필드를 공유한다. 이는 메모리 사용을 절약하고 복사 속도가 빠르지만, 한쪽의 변경이 다른 쪽에 영향을 미칠 수 있는 단점이 있다.
- 깊은 복사(Deep Copy)
- 원본 객체와 복사된 객체가 참조형 필드를 독립적으로 소유한다. 이는 데이터 무결성을 보장하지만, 복사 비용이 크고 구현이 복잡할 수 있다.
간단히 표로 정리하면 다음과 같다.
복사 방법 | 원본 객체와 복사된 객체의 관계 |
약한 복사 | 참조형 필드를 공유 |
깊은 복사 | 참조형 필드를 독립적으로 복사 |
- 깊은 복사는 객체의 모든 종속 객체까지 복사하기 때문에 더 안전하지만, 그만큼 비용이 많이 든다. 반면, 약한 복사는 빠르고 메모리 사용을 절약할 수 있지만, 참조형 필드의 공유로 인해 의도치 않은 부작용이 발생할 수 있다.
3. 자바에서 약한 복사 구현 방법
자바에서 객체를 복사하는 대표적인 방법은 다음과 같다.
- clone() 메서드 사용
- 복사 생성자(Copy Constructor)
- 정적 팩토리 메서드(Static Factory Method)
- Apache Commons Lang의 SerializationUtils 활용
- Spring의 BeanUtils 활용
이 중에서 복사 생성자와 정적 팩토리 메서드는 가독성이 좋고, clone() 메서드보다 더 안전하게 약한 복사를 구현할 수 있는 방법이다.
4. 주소록과 연락처로 알아보는 약한 복사
주소록과 연락처
- 약한 복사를 보다 쉽게 이해하기 위해 주소록(Address Book)과 연락처(Contact)를 사용하여 설명을 만들어 봤다. 이를 통해 약한 복사가 실제로 어떻게 동작하는지를 명확히 확인할 수 있을 것이다.
Contact(연락처) 클래스
- 아래의 Contact 클래스는 개인의 이름과 전화번호를 저장하는 단순한 클래스다. 이 클래스는 연락처 정보를 관리하는 전용 메서드를 포함하고 있으며, Cloneable 인터페이스를 구현하여 복제가 가능하도록 한다.
class Contact implements Cloneable {
private String name;
private String phoneNumber;
public Contact(String name, String phoneNumber) {
this.name = name;
this.phoneNumber = phoneNumber;
}
/**
* 연락처 이름을 변경하는 전용 메서드
* @param name 새로운 이름
*/
public void updateName(String name) {
this.name = name;
}
/**
* 연락처 전화번호를 변경하는 전용 메서드
* @param phoneNumber 새로운 전화번호
*/
public void updatePhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 얕은 복사 수행
}
// getter 선언
public String getName() {
return name;
}
public String getPhoneNumber() {
return phoneNumber;
}
@Override
public String toString() {
return "Contact{" +
"name='" + name + '\'' +
", phoneNumber='" + phoneNumber + '\'' +
'}';
}
}
AddressBook(주소록) 클래스
- AddressBook 클래스는 주소록의 제목과 여러 Contact 객체를 포함하는 리스트를 관리한다. 이 클래스는 주소록을 복사하는 기능을 제공하며, 약한 복사를 구현하기 위해 clone() 메서드를 오버라이드한다.
public class AddressBook implements Cloneable {
private String title;
private List<Contact> contacts;
public AddressBook(String title) {
this.title = title;
this.contacts = new ArrayList<>();
}
/**
* 새로운 연락처를 주소록에 추가하는 메서드
* @param contact 추가할 연락처
*/
public void addContact(Contact contact) {
this.contacts.add(contact);
}
/**
* 특정 인덱스의 연락처를 가져오는 메서드
* @param index 연락처의 인덱스
* @return 해당 인덱스의 연락처
*/
public Contact getContact(int index) {
return this.contacts.get(index);
}
/**
* 주소록의 제목을 변경하는 전용 메서드
* @param title 새로운 제목
*/
public void updateTitle(String title) {
this.title = title;
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 얕은 복사 수행
}
// getter 선언
public String getTitle() {
return title;
}
public List<Contact> getContacts() {
return contacts;
}
@Override
public String toString() {
return "AddressBook{" +
"title='" + title + '\'' +
", contacts=" + contacts +
'}';
}
}
약한 복사(Shallow Copy) 예제
- 위에서 정의한 AddressBook과 Contact 클래스를 사용하여 약한 복사가 어떻게 동작하는지 확인해 보자.
public class ShallowCopyDemo {
public static void main(String[] args) {
// 원본 주소록 생성
AddressBook originalAddressBook = new AddressBook("회사 주소록");
originalAddressBook.addContact(new Contact("홍길동", "010-1234"));
originalAddressBook.addContact(new Contact("김영희", "010-5678"));
originalAddressBook.addContact(new Contact("이철수", "010-9012"));
try {
// 약한 복사 수행
AddressBook copiedAddressBook = (AddressBook) originalAddressBook.clone();
copiedAddressBook.updateTitle("회사 주소록 (복사됨)");
copiedAddressBook.addContact(new Contact("박지수", "010-3456"));
System.out.println("원본 주소록:");
System.out.println(originalAddressBook);
System.out.println("\n복사된 주소록:");
System.out.println(copiedAddressBook);
// 복사된 주소록의 첫 번째 연락처 수정
copiedAddressBook.getContact(0).updatePhoneNumber("010-9999");
System.out.println("\n번호 수정 후 원본 주소록:");
System.out.println(originalAddressBook);
System.out.println("\n번호 수정 후 복사된 주소록:");
System.out.println(copiedAddressBook);
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
}
}
실행 결과
원본 주소록:
AddressBook{title='회사 주소록', contacts=[Contact{name='홍길동', phoneNumber='010-1234'}, Contact{name='김영희', phoneNumber='010-5678'}, Contact{name='이철수', phoneNumber='010-9012'}, Contact{name='박지수', phoneNumber='010-3456'}]}
복사된 주소록:
AddressBook{title='회사 주소록 (복사됨)', contacts=[Contact{name='홍길동', phoneNumber='010-1234'}, Contact{name='김영희', phoneNumber='010-5678'}, Contact{name='이철수', phoneNumber='010-9012'}, Contact{name='박지수', phoneNumber='010-3456'}]}
번호 수정 후 원본 주소록:
AddressBook{title='회사 주소록', contacts=[Contact{name='홍길동', phoneNumber='010-9999'}, Contact{name='김영희', phoneNumber='010-5678'}, Contact{name='이철수', phoneNumber='010-9012'}, Contact{name='박지수', phoneNumber='010-3456'}]}
번호 수정 후 복사된 주소록:
AddressBook{title='회사 주소록 (복사됨)', contacts=[Contact{name='홍길동', phoneNumber='010-9999'}, Contact{name='김영희', phoneNumber='010-5678'}, Contact{name='이철수', phoneNumber='010-9012'}, Contact{name='박지수', phoneNumber='010-3456'}]}
결과 해석
- 초기 복사
- 원본 originalAddressBook과 복사본 copiedAddressBook은 서로 다른 AddressBook 객체다. 하지만 두 주소록이 포함하고 있는 Contact 객체들은 동일한 참조를 가진다. 즉, 두 주소록은 같은 연락처 객체들을 공유하게 된다.
- 원본 originalAddressBook과 복사본 copiedAddressBook은 서로 다른 AddressBook 객체다. 하지만 두 주소록이 포함하고 있는 Contact 객체들은 동일한 참조를 가진다. 즉, 두 주소록은 같은 연락처 객체들을 공유하게 된다.
- 주소록 자체 변경
- 복사본 copiedAddressBook에 새로운 연락처 '박지수'를 추가해도 원본인 originalAddressBook에는 영향을 주지 않는다. 이는 주소록 객체 자체는 독립적이지만, 내부의 연락처 객체들은 공유하기 때문이다.
- 복사본 copiedAddressBook에 새로운 연락처 '박지수'를 추가해도 원본인 originalAddressBook에는 영향을 주지 않는다. 이는 주소록 객체 자체는 독립적이지만, 내부의 연락처 객체들은 공유하기 때문이다.
- 연락처 변경
- 복사본 copiedAddressBook의 첫 번째 연락처 홍길동의 전화번호를 변경하면, 원본 originalAddressBook에서도 동일한 연락처의 전화번호가 변경된다. 이는 두 주소록이 동일한 Contact 객체를 참조하고 있기 때문에 발생하는 현상이다.
- 복사본 copiedAddressBook의 첫 번째 연락처 홍길동의 전화번호를 변경하면, 원본 originalAddressBook에서도 동일한 연락처의 전화번호가 변경된다. 이는 두 주소록이 동일한 Contact 객체를 참조하고 있기 때문에 발생하는 현상이다.
시각화를 통해 원본과 복제본의 관계를 알아보자.
초기 상태: 원본 주소록과 복사된 주소록이 존재한다.
원본: Original Address Book (주소록 제목: 회사 주소록)
+------------------------------+
| Address Book Title: 회사 주소록 |
| Contacts: |
| - 홍길동, 010-1234 |
| - 김영희, 010-5678 |
| - 이철수, 010-9012 |
+------------------------------+
|
| (Shallow Copy)
V
복사본: Copied Address Book (주소록 제목: 회사 주소록 (복사됨))
+-------------------------------------+
| Address Book Title: 회사 주소록 (복사됨) |
| Contacts: |
| - 홍길동, 010-1234 ↗ |
| - 김영희, 010-5678 ↗ |
| - 이철수, 010-9012 ↗ |
| - 박지수, 010-3456 |
+-------------------------------------+
- 화살표 ↗는 원본 originalAddressBook과 복제본 copiedAddressBook이 동일한 Contact 객체를 참조하고 있음을 나타낸다.
- copiedAddressBook은 복제된 후 새로운 연락처 '박지수'를 추가한다. 그래서 기존 3개의 연락처에 '박지수'가 추가되어 있는 것을 확인할 수 있다. 다만 복제본에 추가된 '박지수'는 원본에는 추가되지 않는다. 그리고 원본에서 복제한 다른 3개의 주소들은 원본, 복제본이 계속해서 공유하게 된다.
연락처 변경 후: 원본과 복사된 주소록 모두 변경된다.
원본: Original Address Book (주소록 제목: 회사 주소록)
+------------------------------+
| Address Book Title: 회사 주소록 |
| Contacts: |
| - 홍길동, 010-9999 |
| - 김영희, 010-5678 |
| - 이철수, 010-9012 |
+------------------------------+
|
| (Shallow Copy)
V
복사본: Copied Address Book (주소록 제목: 회사 주소록 (복사됨))
+--------------------------------------+
| Address Book Title: 회사 주소록 (복사됨) |
| Contacts: |
| - 홍길동, 010-9999 ↗ |
| - 김영희, 010-5678 ↗ |
| - 이철수, 010-9012 ↗ |
| - 박지수, 010-3456 |
+--------------------------------------+
- 복제본 copiedAddressBook의 첫 번째 연락처 홍길동의 전화번호를 "010-9999"로 변경하면, 원본 originalAddressBook에서도 동일하게 변경된다. 이는 두 주소록이 동일한 Contact 객체를 참조하고 있기 때문이다.
5. 실전 예제 코드로 이해하기
1. Clone 메서드 사용 예제
- 먼저, 기본적인 clone() 메서드를 사용한 약한 복사 예제를 보자. Address와 Person 클래스를 선언한다.
public class Address implements Cloneable {
private String phoneNumber;
public Address(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
/**
* 연락처를 변경하는 전용 메서드
* @param phoneNumber 새로운 전화번호
*/
public void updatePhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 얕은 복사 수행
}
public String getPhoneNumber() {
return phoneNumber;
}
@Override
public String toString() {
return "Address{" +
"phoneNumber='" + phoneNumber + '\'' +
'}';
}
}
public class Person implements Cloneable {
private String name;
private int age;
private final Address address; // final 참조형 필드
public Person(String name, int age, Address address) {
this.name = name;
this.age = age;
this.address = address;
}
/**
* 이름을 변경하는 전용 메서드
* @param name 새로운 이름
*/
public void updateName(String name) {
this.name = name;
}
/**
* 나이를 변경하는 전용 메서드
* @param age 새로운 나이
*/
public void updateAge(int age) {
this.age = age;
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 얕은 복사 수행
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public Address getAddress() {
return address;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
", address=" + address +
'}';
}
}
- 예제를 동작시킬 main 메서드를 선언한다.
public class ShallowCopyExample {
public static void main(String[] args) {
// 인스턴스 생성
Address addr = new Address("010-1234");
Person original = new Person("홍길동", 30, addr);
try {
Person copy = (Person) original.clone();
System.out.println("원본 객체:");
System.out.println(original);
System.out.println("복사된 객체:");
System.out.println(copy);
// 복사된 객체의 이름 변경 및 연락처 변경
copy.updateName("김철수");
copy.getAddress().updatePhoneNumber("010-5678");
System.out.println("\n수정 후 원본 객체:");
System.out.println(original);
System.out.println("수정 후 복사된 객체:");
System.out.println(copy);
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
}
}
실행 결과
원본 객체:
Person{name='홍길동', age=30, address=Address{phoneNumber='010-1234'}}
복사된 객체:
Person{name='홍길동', age=30, address=Address{phoneNumber='010-1234'}}
수정 후 원본 객체:
Person{name='홍길동', age=30, address=Address{phoneNumber='010-5678'}}
수정 후 복사된 객체:
Person{name='김철수', age=30, address=Address{phoneNumber='010-5678'}}
- 복사 후
- original과 copy 객체는 동일한 Address 객체를 참조하고 있다.
- original과 copy 객체는 동일한 Address 객체를 참조하고 있다.
- 복사된 객체 수정 후
- copy.updateName("김철수")를 호출하여 copy의 이름을 변경하면, original의 이름은 변경되지 않는다. 하지만, copy.getAddress().updatePhoneNumber("010-5678")를 호출하면 original 객체의 address.phoneNumber도 "010-5678"로 변경된다. 이는 두 객체가 동일한 Address 객체를 공유하기 때문이다.
2. 복사 생성자를 이용한 DTO to Entity 변환
- 이번에는 복사 생성자를 사용하여 DTO를 Entity로 변환하는 예제를 보자. 이 방법은 가독성이 좋고, 객체 생성 시 필요한 필드만 명확히 설정할 수 있어 실무에서 자주 사용된다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
@Getter
@Entity
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // pk
private String username;
private final String password; // 민감한 정보, final로 참조 고정
private String email;
private final Address address; // final 참조형 필드
// 복사 생성자
public UserEntity(UserDTO dto, String password) {
this.username = dto.getUsername();
this.password = password;
this.email = dto.getEmail();
this.address = dto.getAddress(); // 얕은 복사
}
/**
* 이메일을 변경하는 전용 메서드
* @param email 새로운 이메일 주소
*/
public void updateEmail(String email) {
this.email = email;
}
/**
* 주소의 전화번호를 변경하는 전용 메서드
* @param phoneNumber 새로운 전화번호
*/
public void updatePhoneNumber(String phoneNumber) {
this.address.updatePhoneNumber(phoneNumber);
}
}
- dto 클래스도 선언한다.
@Getter
@ToString
public class UserDTO {
private final String username;
private final String email;
private final Address address;
/**
* 정적 팩토리 메서드로 DTO 생성
* @param username 사용자 이름
* @param email 사용자 이메일
* @param address 사용자 주소
* @return 새로운 UserDTO 인스턴스
*/
public static UserDTO create(String username, String email, Address address) {
return new UserDTO(username, email, address);
}
private UserDTO(String username, String email, Address address) {
this.username = username;
this.email = email;
this.address = address;
}
}
- 변환은 다음과 같이 진행한다.
public class DTOToEntityExample {
public static void main(String[] args) {
// 인스턴스 생성
Address addr = new Address("010-1234");
UserEntity user = new UserEntity("john_doe", "password123", "john@example.com", addr);
// DTO로 변환 (정적 팩토리 메서드 사용)
UserDTO dto = UserDTO.create(user.getUsername(), user.getEmail(), user.getAddress());
// DTO 수정 (Address 객체는 여전히 공유됨)
// DTO 자체는 불변이지만, Address 객체는 변경 가능
Address modifiedAddress = dto.getAddress();
modifiedAddress.updatePhoneNumber("010-5678");
// DTO를 Entity로 변환 (복사 생성자 사용)
UserEntity updatedEntity = new UserEntity(dto, "password123");
System.out.println("엔티티 객체: " + user);
System.out.println("DTO 객체: " + dto);
System.out.println("업데이트된 엔티티 객체: " + updatedEntity);
}
}
실행 결과
엔티티 객체: UserEntity(username=john_doe, password=password123, email=john@example.com, address=Address(phoneNumber=010-5678))
DTO 객체: UserDTO(username=john_doe, email=john@example.com, address=Address(phoneNumber=010-5678))
업데이트된 엔티티 객체: UserEntity(username=john_doe, password=password123, email=john@example.com, address=Address(phoneNumber=010-5678))
- DTO 변환 후
- UserDTO 객체는 UserEntity의 일부 필드만 복사한다. UserDTO는 불변 객체로 설계되었으며, 모든 필드는 final로 선언되어 있다.
- UserDTO 객체는 UserEntity의 일부 필드만 복사한다. UserDTO는 불변 객체로 설계되었으며, 모든 필드는 final로 선언되어 있다.
- DTO 수정 후
- dto.getAddress().updatePhoneNumber("010-5678")를 호출하면 user.getAddress().getPhoneNumber()도 "010-5678"로 변경된다. 이는 UserDTO와 UserEntity가 동일한 Address 객체를 참조하기 때문이다.
- dto.getAddress().updatePhoneNumber("010-5678")를 호출하면 user.getAddress().getPhoneNumber()도 "010-5678"로 변경된다. 이는 UserDTO와 UserEntity가 동일한 Address 객체를 참조하기 때문이다.
- 보안 유지
- 민감한 정보인 password는 DTO에 포함되지 않아 안전하게 데이터를 전달할 수 있다.
참고사항. final 키워드와 객체의 변경 가능성
@Getter
@ToString
public class UserDTO {
private final String username;
private final String email;
private final Address address;
/**
* 정적 팩토리 메서드로 DTO 생성
* @param username 사용자 이름
* @param email 사용자 이메일
* @param address 사용자 주소
* @return 새로운 UserDTO 인스턴스
*/
public static UserDTO create(String username, String email, Address address) {
return new UserDTO(username, email, address);
}
private UserDTO(String username, String email, Address address) {
this.username = username;
this.email = email;
this.address = address;
}
}
- 위 코드에서 UserDTO의 address 필드는 final로 선언되어 있다. 이는 address 필드가 한 번 할당되면 다른 Address 객체로 변경될 수 없음을 의미한다.(다른 인스턴스 주소를 참조할 수 없다는 의미.) 그러나 Address 클래스 내부의 필드들은 여전히 변경이 가능하다.
@Getter
@ToString
public class Address implements Cloneable {
private String phoneNumber;
public Address(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
/**
* 연락처를 변경하는 전용 메서드
* @param phoneNumber 새로운 전화번호
*/
public void updatePhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 얕은 복사 수행
}
}
- 따라서, UserDTO의 address 필드가 참조하는 Address 객체의 내부 상태는 변경할 수 있다. final 키워드는 객체의 참조 주소를 변경할 수 없게 만드는 것이지, 한번 참조한 객체 자체의 내부 상태 변경을 막지는 않는다. 이는 다음과 같이 이해할 수 있다.
- final 참조 변수: 참조 변수 자체는 다른 객체를 가리킬 수 없다. (초기화 때 정해진 주소를 고정으로 바라봄: 변경 불가)
- 불변 객체 (Immutable Object): 객체의 상태를 변경할 수 없도록 설계된 객체다.
- 가변 객체 (Mutable Object): 객체의 상태를 변경할 수 있는 객체다.
- 위 예제에서 Address는 가변 객체다. final로 선언된 address 필드는 다른 Address 객체로 변경할 수 없지만(final이라 필드가 참조하는 객체의 주소 변경은 불가능), Address 객체 내부의 phoneNumber 필드는 변경할 수 있다.
final 키워드의 영향
- final 키워드는 객체의 참조를 변경할 수 없도록 하지만, 객체 자체가 가변적이라면 내부 상태는 변경할 수 있다. 이를 통해 다음과 같은 장단점을 이해할 수 있다.
- 장점
- 객체의 참조가 불변이므로, 참조의 변경으로 인한 실수를 방지할 수 있다.
- 코드의 가독성이 높아지고, 객체의 상태 관리가 명확해진다.
- 단점
- 객체 자체가 가변적이라면, 여전히 내부 상태의 변경이 가능하므로 완전한 불변성을 보장하지 않는다. (위의 예시)
- 가변 객체와 결합될 경우, 예상치 못한 부작용이 발생할 수 있다.
3. 정적 팩토리 메서드를 이용한 DTO to Entity 변환
- 이번에는 정적 팩토리 메서드를 사용하여 DTO를 Entity로 변환하는 예제를 보도록 하자. 이 방법은 객체 생성 로직을 캡슐화하여 보다 유연하고 가독성 높은 코드를 작성할 수 있게 도와준다. (나는 주로 펙토리 메서드를 사용한다.)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
@Getter
@Entity
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // pk
private String username;
private final String password; // 민감한 정보, final로 참조 고정
private String email;
private final Address address; // final 참조형 필드
// 정적 팩토리 메서드 (약한 복사)
public static UserEntity fromDTO(UserDTO dto, String password) {
return new UserEntity(null, dto.getUsername(), password, dto.getEmail(), dto.getAddress());
}
/**
* 이메일을 변경하는 전용 메서드
* @param email 새로운 이메일 주소
*/
public void updateEmail(String email) {
this.email = email;
}
/**
* 주소의 전화번호를 변경하는 전용 메서드
* @param phoneNumber 새로운 전화번호
*/
public void updatePhoneNumber(String phoneNumber) {
this.address.updatePhoneNumber(phoneNumber);
}
}
- dto를 선언한다.
@Getter
@ToString
public class UserDTO {
private final String username;
private final String email;
private final Address address;
/**
* 정적 팩토리 메서드로 DTO 생성
* @param username 사용자 이름
* @param email 사용자 이메일
* @param address 사용자 주소
* @return 새로운 UserDTO 인스턴스
*/
public static UserDTO create(String username, String email, Address address) {
return new UserDTO(username, email, address);
}
private UserDTO(String username, String email, Address address) {
this.username = username;
this.email = email;
this.address = address;
}
}
- 아래와 같이 사용한다.
public class FactoryMethodExample {
public static void main(String[] args) {
// 인스턴스 생성
Address addr = new Address("010-1234");
UserDTO dto = UserDTO.create("jane_doe", "jane@example.com", addr);
// Entity로 변환 (정적 팩토리 메서드 사용)
UserEntity entity = UserEntity.fromDTO(dto, "securePassword!");
// Entity 수정
entity.updateEmail("jane_doe@example.com");
entity.updatePhoneNumber("010-5678");
System.out.println("DTO 객체: " + dto);
System.out.println("Entity 객체: " + entity);
}
}
실행 결과
DTO 객체: UserDTO(username=jane_doe, email=jane@example.com, address=Address(phoneNumber=010-5678))
Entity 객체: UserEntity(username=jane_doe, password=securePassword!, email=jane_doe@example.com, address=Address(phoneNumber=010-5678))
- Entity 변환 후
- UserEntity 객체는 UserDTO의 일부 필드와 추가적인 password 필드를 포함하여 생성된다.
- UserEntity 객체는 UserDTO의 일부 필드와 추가적인 password 필드를 포함하여 생성된다.
- Entity 수정 후
- entity.updatePhoneNumber("010-5678")를 호출하면 dto.getAddress().getPhoneNumber()도 "010-5678"로 변경된다. 이는 UserDTO와 UserEntity가 동일한 Address 객체를 참조하기 때문이다.
- entity.updatePhoneNumber("010-5678")를 호출하면 dto.getAddress().getPhoneNumber()도 "010-5678"로 변경된다. 이는 UserDTO와 UserEntity가 동일한 Address 객체를 참조하기 때문이다.
- 보안 유지
- DTO를 통해 민감한 정보인 password를 직접 노출하지 않고, 정적 팩토리 메서드를 통해 안전하게 Entity를 생성할 수 있다.
6. 약한 복사를 사용할 때의 주의사항
약한 복사는 객체의 참조형 필드를 공유하게 되므로, 다음과 같은 문제를 일으킬 수 있다.
공유된 참조의 변경
- 예기치 않은 변경: 복사된 객체에서 참조형 필드를 변경하면 원본 객체에도 영향을 미친다.
- 스레드 안전성 문제: 여러 스레드에서 동일한 참조형 필드를 동시에 수정하려 할 때 문제가 발생할 수 있다.
해결 방안
- 불변 객체(Immutable Objects) 사용: 참조형 필드를 불변 객체로 설계하여, 변경 불가능하게 한다.
- 필요시 깊은 복사 사용: 공유가 의도되지 않은 경우, 깊은 복사를 통해 독립적인 객체를 생성한다.
스레드 안전성 (Thread Safety)
- 데이터 일관성 문제: 여러 스레드가 동일한 참조형 필드를 동시에 수정하면 데이터 일관성이 깨질 수 있다.
해결 방안
- 동기화(Synchronization): 공유된 필드를 접근할 때 동기화를 통해 스레드 안전성을 보장한다.
- 불변 객체 사용: 불변 객체는 상태 변경이 불가능하므로, 스레드 안전성을 자연스럽게 보장한다.
반응형