해당 블로그는 Object의 final이 아닌 메서드 equals, hashcode, toString, clone, finalize에 대한 내용으로 모두 재정의를 염두해 두고 설계된 메서드들이기에 활용 방법에 대해 설명한다.
Item 10 : equals는 일반 규약을 지켜 정의하라.
기본적으로 equals는 자기 자신 즉, 인스턴스와 비교하기에 재정의를 통해 필드가 같은지 등을 확인할 수 있다.
equals 재정의에 대한 경우
- 객체 식별성이 아닌, 논리적 동치성을 검사하는데 상위 클래스에서 재정의 되지 않은 경우
- 값 클래스의 경우
- Map의 키와 Set의 원소로 활용되는 경우
- 싱글턴 객체의 경우는 정의가 필요없다.
equals 재정의 규약
- 반사성 : 같은 인스턴스는 같다.
- 대칭성 : x.equals(y) 면, y.equals(x)이다.
- 추이성 : x와 y가 같고 y와 z가 같다면 x와 z도 같다. (같다는 equals를 의미)
- 일관성 : equals를 계속 호출해도 같은 조건에 대해 같은 값이 나온다.
- null이 아님 : 모든 객체는 null이 아니여야 한다.
정의하기 어려운 경우가 존재한다. 바로 '상위 클래스의 필드에 새로운 필드를 추가해 하위 클래스를 구현하는 경우 equals를 구현하기 어렵고 불가능하다. 이러한 경우에는 상속 대신 컴포지션을 사용한다.
즉, 하위 클래스를 활용하는 것이 아닌 상위 클래스를 하위 클래스의 필드로 넣어서 사용한다.
이를 통해, 상위 클래스와 하위 클래스를 직접 비교하는 것은 불가능해지고 하위 클래스끼리만 비교하게 된다.
equals 재정의 방법
// 4-0. equals의 매개변수는 Object로 설정한다.
// 4-1. == 연산자를 활용해 입력이 자기자신의 참조인지 확인한다.
// 4-2. instanceof 연산자로 입력이 올바를 타입인지 확인한다.
// 4-3. 입력을 올바른 형태로 형변환한다.
// 4-4. 입력 객체와 자기 자신의 대응하는 '핵심'필드들이 모두 일치하는지 하나씩 검사한다.
추가적으로, 비용이 큰 필드부터 비교해 간다.
또한, 기본적으로 equals 메서드를 제공해주는 Lombok등을 활용하는 것도 좋은 방안이다.
Spring에서 적용
기본적으로는 필드 값 비교를 통해 equals를 구현하면 된다.
하지만 프로젝트에서 Entity 구현시 부모 클래스와 자식 클래스 형태로 구현하는 경우가 많고 이러한 경우에는 해당 방법만 적용해서는 안된다. equals 호출시 StackOverFlow가 발생하기 때문인데, 아래의 예시를 통해 살펴보자.
아래 코드는 가게의 쉬는날 정보를 담은 부모 클래스와 실제 쉬는날들을 담은 자식 클래스를 의미한다.
추가적으로 BaseEntity는 객체의 수정 정보를 담은 클래스이다.
- 부모 클래스
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false, exclude="childSet")
@Table(name = "StoreRestDays")
public class StoreRestDaysEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long daysId;
private int storeId;
@OneToMany(mappedBy = "storeRestDaysEntity", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
private Set<StoreRestDaysMapEntity> childSet;
public StoreRestDaysEntity(int storeId) {
this.storeId = storeId;
}
}
- 자식 클래스
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@Table(name = "StoreRestDaysMap")
public class StoreRestDaysMapEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long dayId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "days_id")
@JsonBackReference
private StoreRestDaysEntity storeRestDaysEntity;
private String date;
public StoreRestDaysMapEntity(String date, StoreRestDaysEntity storeRestDaysEntity) {
this.date = date;
this.storeRestDaysEntity = storeRestDaysEntity;;
}
}
이러한 경우 equals 사용에 대한 주의가 필요하다.
나는 해결 방안으로 우선, BaseEntity에 대한 비교는 하지 않았다. 수정된 날짜는 비교할 필요가 적기 때문이었고
두번째로, 부모클래스의 비교에 대해서 자식 클래스 비교는 제외시켰다. 이유는 부모 클래스는 데이터 베이스에 저장되기에 고유한 아이디를 이미 갖는다. 따라서, 아이디가 같다면 같은 클래스이기에 제외시켰다. 하지만, 자식 클래스의 비교는 부모 클래스도 비교하게 설정해두었다.
하지만, 이러한 규칙을 만들지 않았다면, equals 호출시 StackOverFlow 문제가 발생하였을 것이다. 자식 클래스에서 메서드 호출시 부모 클래스의 메서드도 호출되고 또 다시 자식 클래스의 메서드가 호출되기 때문이다.
따라서, 개발자마다 자신만의 규칙을 정해서 이를 해결하는 것이 필요할 것이다.
Item 11 : equals를 재정의하려거든 HashCode도 재정의하라.
정의하지 않으면 Hash를 사용한 컬렉션에서 문제를 일으킨다.
hashcode 규약
// 1. equals 비교에 사용되는 정보가 변경되지 않았다면 애플리케이션이 실행되는 동안 그 객체의 hashcode는 항상 같아야 한다.
// (애플리케이션 종료 후 재실행시 변경될 수 있다.)
// 2. equals가 두 객체가 같다고 판단했다면, 두 객체의 hashcode도 같아야 한다.
// 3. equals가 두 객체가 다르다고 판단했더라도, hashcode는 같을 수 있다. 하지만 달라야 성능이 좋은 해시테이블이다.
hashcode 작성법
// 1. int변수 result를 선언한 후 c로 초기화 한다.
// (이 때, c는 해당 객체의 첫번째 핵심 필드를 단계 2-1방식으로 계산한 해시코드이다.)
// (핵심 필드란, equals에서 비교에 사용되는 필드를 의미한다.)
// 2. 해당 객체의 나머지 핵심 필드 f에 대해 아래의 작업들을 수행한다.
// 2-1. 해당 필드의 해시코드 c를 계산한다.
// 2-1-1. 기본 타입 필드라면, Type.hashcode(f)를 수행한다.
// (ex. Integer.hashcode(int i))
// 2-1-2. 참조 타입 필드이면서 해당 클래스의 equals 메서드가 참조 필드의 equals를 재귀적으로 호출해 비교한다면
// hashCode도 재귀적으로 호출될 것이다.
// - 계산이 복잡해질 것 같으면 해당 필드의 표준형을 만들어 그 표준형의 hashCode를 호출하고, 만약 해당 필드가 null이라면 0을 사용한다.
// 2-1-3. 배열 타입 필드라면([] 타입), 핵심 원소 각각을 별도 필드처럼 다룬다.
// - 각 핵심 원소의 해시값을 계산하고 2-2 방법을 적용한다.
// - 만약, 핵심원소가 없다면? 단순히 상수 사용
// - 만약, 모두 핵심원소라면? Arrays.hashCode사용
// 2-2. 위에서 계산한 c로 result를 갱신한다.
// (result = 31*result+c)
// 3. result를 반환한다.
추가적으로, 아래 사항을 고려해볼 수 있다.
// - 파생필드는 해시코드 계산에서 제외 가능하다.
// - 즉, 다른 필드로 부터 계산해 낼 수 있는 필드는 모두 무시할 수 있다.
// - equals에 사용되지 않는 필드는 반드시 해시코드 계산에서 제외한다.
// (어길시 equals에서 같다고 판단한 객체에 대해서 다른 해시코드가 나올 수 있다.)
// - 클래스가 불변이고, 해시코드를 계산하는 비용이 크다면 매번 새로 계산하는 방법보단 캐싱을 하자.
// - 지연 초기화를 통해 이를 구현 (주의 사항 : 스레드 안전하게 만들도록 신경써야 함. (Item 83))
// - 성능을 높이기 위해 해시코드를 계산할 때, 핵심필드를 생략해서는 안된다.
// (속도는 빨라질 수 있지만 심각한 성능 저하를 불러일으킨다.)
// - 해시코드가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자.
// (그래야 클라이언트가 이 값에 의지하지 않고 나중에 변경 가능하다.)
Spring에서의 적용
기본적으로 라이브러리를 사용하여 구현할 수 있다. 위에서 Lombok을 사용하였는데, @EqualsAndHashCode이다. 그리고 뒤에 추가적인 제약을 설정하면 Equals 와 HashCode 둘다 적용된다. 한번에 적용될 수 있는 이유는 위의 추가사항에서 확인이 가능하다. 보통 HashCode 재정의시 Equals의 내용을 많이 따르기 때문이다.
Item 12 : toString을 항상 재정의하라.
toString을 정의하는 이유는 무엇일까? 책에서는 표현하기에 너무 좋고 디버깅이 쉽다고 표현하고 있다. 규약 또한 간결하면서도 유익한 정보를 제공하고 모든 클래스에서 이를 재정의하기를 권고한다고 표현한다. 따라서 재정의는 필수에 가깝다고 생각할 수 있다.
toString이 자동 호출되는 경우
- println
- 문자열 연결 연산자(+)
- assert 구문
- 디버거가 객체를 출력할 때
- 객체를 참조하는 컴포넌트가 오류 메세지를 로깅할 때
추가사항
// 1. 좋은 toString은 해당 인스턴스를 포함하는 컬렉션에서 유용하게 쓰인다.
// - toString 적용시 그냥 출력시에도 toString으로 변환한 결과가 출력된다.
// 2. 실전에서 toString은 그 객체가 가진 중요한 정보를 모두 반환하는게 좋다.
// - 객체의 상태가 문자열로 표현하기 적합하지 않다면 요약한 결과를 반환하자.
// 3. 반환값의 포맷을 문서화할지 정하자.
// - 전화번호나 행렬같은 클래스라면 문서화를 권함.
// - 값 그대로 출력하거나 csv와 같은 파일작성도 가능하다.
// - 포맷을 명시하기로 했다면, 문자열<->객체 상호 변환가능하도록 해주는 정적팩터리 매서드나 생성자를 함께 만들자.
// - BigInteger, BigDecimal가 위의 예시
// - 하지만 단점으로 포맷을 명시하면 그 포맷의 얽매이게 된다.
// 4. 포맷을 명시하든 아니든 제작 의도는 명확히 밝혀야 한다.
// -> 설명을 적어주자, 그래야 변경시 피해 방지
// 5. 포맷 명시 여부와 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자.
// -> 필드 값들을 반환할 수 있는 API가 없다면(getter, setter) toString의 반환값들을 파싱할 수 밖에 없다.
// 이는 성능 문제를 야기시킨다.
// 6. 정적 유틸리티(아이템 4)는 toString을 제공할 필요가 없다.
// 7. 열거타입 또한 자바에서 이미 완벽한 toString을 제공하니 재정의 필요가 없다.
// 8. 하위 클래스들이 공유할 문자열 표현이 있는 추상 클래스라면 구현해야 한다.
// -> 대부분의 컬렉션 구현체는 추상 컬렉션 클래스들의 toString 메서드를 상속해서 사용
Spring에서 적용
Spring 프로젝트에서 Lombok을 통해 toString을 제공가능하다. 아래는 기본 예시 코드이다.
아래 필드를 갖는 MemberDTO 클래스에 대해 아래와 같이 toString을 표현하게 된다.
private String userId;
private String userPwd;
private String userName;
private String userNumber;
private String userAddress;
private String userEmail;
MemberDTO(userId=id, userPwd=pwd, userName=name, userNumber=number, userAddress=address, userEmail=email)
Lombok을 사용시 기본적으로 클래스명(필드1 = 필드값, ...)으로 표현된다.
기본적으로는 잘 표현된다. 하지만 추가적으로 정의가 필요한 상황이 존재한다.
- 필드 명이 의미가 잘 표현되지 못한 경우 : 설명을 또한 재정의가 필요하다
- 부모 클래스, 자식 클래스 구조인 경우 : 계속 서로 호출될 수 있다.
두번째 예시는 위에서 본것 처럼 부모 클래스-자식 클래스 구조인데, equals에서 정의한 것처럼 자신만의 규칙을 정해 호출여부를 정해주는 것이 좋다. 그렇지 않으면, 재귀가 반복되어 StackOverFlow 오류가 발생할 것이다.
Item 13 : clone 재정의는 주의해서 진행하라
책에서는 clone 메서드에 대해 단점이 너무 많다고 설명한다.
- 생성자를 쓰지 않아 생성하는 과정에서 오류가 발생할 수 있다.
- clone의 규약이 정확하지 않다.
- final 필드를 제대로 사용하지 못한다.
- 불필요한 예외, 형변환이 재정의시 필요하다.
따라서, 복사 생성자나 복사 팩터리의 사용을 권한다고 설명한다.
Spring에 적용
프로젝트에 여러 clone의 단점을 고려해 복사 생성자를 활용하여 clone의 개념을 정의하였다.
public MemberDTO(MemberDTO other) {
this.userId = other.userId;
this.userPwd = other.userPwd;
this.userName = other.userName;
this.userNumber = other.userNumber;
this.userAddress = other.userAddress;
this.userEmail = other.userEmail;
}
추가적으로, 부모클래스-자식클래스를 갖는 엔터티에 대해서도 안전하지 못하다. 즉, 부모 클래스의 필드 중 자식 클래스가 있다면 같은 인스턴스를 복사하게 된다. 따라서, 한 인스턴스에서 자식 클래스 변경시 복사본도 변경이 된다. 따라서 해당 부분에 대해 또 다른 복사 생성자를 활용해 구현하는 것이 좋다. 하지만 위에서 살펴본 것처럼 재귀를 통한 StackOverFlow도 발생가능하니 해당 부분을 주의해서 재정의 해야한다.
Item 14 : Comparable을 구현할지 고려하라.
필요한 이유 : List등의 정렬 메서드 등 순서와 관련된 메서드 구현이 필요한 경우 compareTo를 활용하기 때문이다.
기본적으로 왼쪽 객체를 기준으로 작다면 음수, 같다면 0, 크다면 양수를 반환한다.
작성 요령은 아래와 같다
// - compareTo 메서드에서 관계연산자 <, >를 사용하는 것은 오류를 발생하니 더이상 추천하지 않는다.
// - 박싱된 기본타입 클래스들에 새로 추가된 정적 메서드인 compare을 이용한다.
char a = 'a';
char b = 'b';
System.out.println(Character.compare(a, b));
System.out.println(Integer.compare(1, 2));
System.out.println("a".compareTo("b"));
// - implements Comparable<Class> 를 작성시 해당 클래스와만 비교한다고 명시한다.
// - 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다.
// - 핵심필드가 여러개라면 가장 핵심적인 필드부터 순차적으로 비교해간다.
BasicInfo basicInfo1 = new BasicInfo("a");
BasicInfo basicInfo2 = new BasicInfo("b");
Info info1 = new Info(basicInfo1, 1);
Info info2 = new Info(basicInfo2, 2);
System.out.println("기본 방식 사용 : " + info1.compareTo(info2));
// - Comparable을 구현하지 않은 필드라면 비교자(Comparator)를 사용한다.
// - 비교자를 연쇄적으로 생성해 비교가 가능하지만 약간의 성능저하가 뒤따른다.
// - 메서드 종류 : comparingInt 등 기본타입 비교, comparing으로 객체 참조 비교
System.out.println("생성자 사용 : " + info1.compareTo(info2));
Spring에 적용
spring에 적용시에는 기본 클래스들에 대해서는 중요도에 따라 설정을 하면 된다. 위에서 계속 살펴본 것처럼 부모-자식 클래스 구조를 갖는 복잡한 부분에 대해서는 각자의 방법이 다르겠지만 본인은 아래와 같이 규칙을 정해 설정하였다.
1. 자식 클래스라면 부모 클래스에 대한 비교는 하지 않는다.
2. 필드의 중요도 순으로 비교를 하자.
3. 부모 클래스라면 자식 클래스 비교를 수행하는데 재귀적으로 자식 클래스의 compareTo 메서드를 호출하도록 설정한다.
'Trouble Shooting > Spring' 카테고리의 다른 글
Effective JAVA를 Spring 프로젝트에 적용해보자 (Item 6~9) (0) | 2023.10.04 |
---|---|
Effective JAVA를 Spring 프로젝트에 적용해보자 (Item 1~5) (0) | 2023.09.27 |