스터디에서 알아가는 것

1. false인 이유

public class Test {
    private String name;

    public Test(String name) {
        this.name = name;
    }

    @Override
        public boolean equals(Object obj) {
            if (obj instanceof Test) {
                return this.name.equals(((Test) obj).name);
            }
            return false;
        }

    public static void main(String[] args) throws Exception {
        Set<Test> set = new HashSet<>();
        Test test = new Test("java");
        set.add(test);
        Test test2 = new Test("java");
        System.out.println(set.contains(test2)); //false
    }
}

위 코드에서 Set<Test>에 동일한 name 값을 가진 객체(testtest2)를 추가하고, 그 후 set.contains(test2)false로 출력되는 이유는 HashSet이 내부적으로 equalshashCode 메서드를 모두 사용해 객체의 동일성을 비교하기 때문이다.

이유

HashSet은 내부적으로 해시 기반 자료구조이다. HashSet이 객체를 저장하거나 조회할 때는 equals뿐만 아니라 hashCode 메서드도 사용한다.

  • equals: 두 객체가 동일한지 비교하는 메서이다. 현재 equals 메서드를 오버라이드하여 name 값만을 기준으로 비교하도록 구현되어 있다. 따라서 testtest2equals 메서드 상으로는 동일한 객체로 판단

  • hashCode: 객체를 해시 테이블에서 빠르게 찾기 위해 사용되는 정수 값이다. HashSet이나 HashMap과 같은 해시 기반 자료구조는 객체를 비교할 때 먼저 hashCode를 비교하고, hashCode가 동일한 경우에만 equals 메서드를 호출하여 최종적으로 동일성을 판단한다.

문제는 현재 Test 클래스에서 hashCode 메서드가 오버라이드되어 있지 않기 때문이다. 기본적으로 Object 클래스의 hashCode는 객체의 메모리 주소를 기반으로 해시 값을 생성하므로, testtest2는 서로 다른 해시 코드를 가진다.

따라서, HashSettesttest2를 서로 다른 객체로 인식하게 되며, set.contains(test2)false를 반환하게 된다.

해결 방법

hashCode 메서드를 equals와 일관되게 오버라이드해야 한다. name 필드를 기준으로 hashCode를 생성하면 testtest2가 같은 해시 코드를 가지게 되어 HashSet이 동일 객체로 인식하게 된다.

수정된 코드는 다음과 같다:

public class Test {
    private String name;

    public Test(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Test) {
            return this.name.equals(((Test) obj).name);
        }
        return false;
    }

    @Override
    public int hashCode() {
        return name.hashCode(); // name 필드를 기준으로 hashCode 생성
    }

    public static void main(String[] args) throws Exception {
        Set<Test> set = new HashSet<>();
        Test test = new Test("java");
        set.add(test);
        Test test2 = new Test("java");
        System.out.println(set.contains(test2)); // true
    }
}

설명:

  • hashCode 오버라이드: hashCode 메서드를 name 필드를 기준으로 오버라이드하여, name이 동일한 객체는 같은 해시 코드를 가지도록 했습니다.

  • 이제 testtest2는 같은 해시 코드를 가지며, HashSet이 이 두 객체를 동일한 객체로 인식하게 됩니다.

이제 set.contains(test2)true를 반환하게 됩니다.

2. 컬렉션 인터페이스를 equals를 언제쓰냐

컬렉션(Collection) 인터페이스에서 equals를 사용하는 경우는 두 컬렉션이 같은지(동일한지)를 비교할 때입니다. Java 컬렉션 프레임워크의 다양한 구현체들은 equals 메서드를 오버라이드하여 두 컬렉션의 요소들이 동일한지를 확인하는 로직을 제공합니다.

1. equals 사용 시점

a) 두 컬렉션이 동일한지 비교할 때

equals 메서드는 두 컬렉션의 크기, 요소의 순서, 그리고 각 요소가 동일한지 여부를 기준으로 두 컬렉션을 비교합니다. 특히 List, Set, Map과 같은 컬렉션 타입에서 각각의 equals 메서드는 해당 타입의 특성을 고려하여 구현되어 있습니다.

예를 들어:

List<String> list1 = new ArrayList<>(Arrays.asList("a", "b", "c"));
List<String> list2 = new ArrayList<>(Arrays.asList("a", "b", "c"));

System.out.println(list1.equals(list2)); // true
  • **List**에서는 요소의 순서가 중요합니다. list1list2의 요소들이 순서대로 모두 동일하기 때문에 equals 결과는 true입니다.

Set<String> set1 = new HashSet<>(Arrays.asList("a", "b", "c"));
Set<String> set2 = new HashSet<>(Arrays.asList("c", "b", "a"));

System.out.println(set1.equals(set2)); // true
  • **Set**은 순서를 고려하지 않고, 요소의 동일성만을 비교합니다. set1set2는 순서는 다르지만 동일한 요소들을 가지고 있으므로 equals 결과는 true입니다.

2. 컬렉션 구현체에서 equals 동작 방식

a) List:

  • List 인터페이스를 구현하는 클래스 (ArrayList, LinkedList 등)에서 equals요소의 순서내용을 기준으로 두 리스트가 동일한지 비교합니다.

  • 요소의 순서가 다르면 equalsfalse를 반환합니다.

List<String> list1 = Arrays.asList("a", "b", "c");
List<String> list2 = Arrays.asList("a", "c", "b");

System.out.println(list1.equals(list2)); // false (순서가 다름)

b) Set:

  • Set 인터페이스를 구현하는 클래스 (HashSet, TreeSet 등)에서 equals요소의 순서는 무시하고 내용만을 기준으로 비교합니다.

  • 같은 요소들이 있으면 equalstrue를 반환합니다.

Set<String> set1 = new HashSet<>(Arrays.asList("a", "b", "c"));
Set<String> set2 = new HashSet<>(Arrays.asList("c", "b", "a"));

System.out.println(set1.equals(set2)); // true (순서가 무시됨)

c) Map:

  • Map 인터페이스를 구현하는 클래스 (HashMap, TreeMap 등)에서 equals는 키(key)와 값(value)이 모두 동일할 때 두 맵이 같다고 판단합니다.

  • 키와 값이 모두 같아야 equalstrue를 반환합니다.

Map<String, String> map1 = new HashMap<>();
map1.put("key1", "value1");
map1.put("key2", "value2");

Map<String, String> map2 = new HashMap<>();
map2.put("key1", "value1");
map2.put("key2", "value2");

System.out.println(map1.equals(map2)); // true

3. 언제 equals를 사용하는가?

  1. 컬렉션의 내용 비교: 두 개의 리스트, 세트, 맵이 같은지 비교할 때 사용합니다. 이때 단순히 객체의 참조값(주소)을 비교하는 것이 아니라 컬렉션 내부의 요소가 동일한지 확인합니다.

  2. 테스트 및 검증: 단위 테스트에서 두 컬렉션이 동일한지(동일한 요소를 가지고 있는지)를 검증할 때 자주 사용됩니다.

List<String> expected = Arrays.asList("a", "b", "c");
List<String> actual = Arrays.asList("a", "b", "c");
assertTrue(expected.equals(actual)); // 같은지 테스트
  1. 데이터의 무결성 확인: 데이터 처리 중 두 컬렉션이 동일한지 확인하는 로직을 통해 데이터가 올바르게 처리되었는지 검증하는 데 사용됩니다.

4. 결론

Collection 인터페이스에서 equals는 컬렉션의 내용을 비교하기 위해 사용됩니다. 각 컬렉션 타입(List, Set, Map)은 자신만의 방식으로 equals를 구현하여 그 특성에 맞게 비교를 수행합니다. List는 순서와 내용을 비교하고, Set은 순서를 무시하고 내용만을 비교하며, Map은 키와 값 모두를 비교합니다.

3. 룸북에서도 equals를 재정의해서 사용하는 경우도 있어? 막.. final로 막아보자는게 뭘까?

네, Lombok을 사용하는 경우에도 equals 메서드를 재정의해서 사용할 수 있습니다. Lombok은 @EqualsAndHashCode 어노테이션을 통해 자동으로 equalshashCode 메서드를 생성해줍니다. 하지만, 때로는 기본으로 제공되는 equals 구현을 변경하거나 사용자 정의를 위해 직접 재정의할 수 있습니다.

1. Lombok에서 equals 메서드 생성

Lombok의 @EqualsAndHashCode 어노테이션을 클래스에 추가하면, 해당 클래스의 모든 필드를 기반으로 equalshashCode 메서드를 자동으로 생성합니다.

import lombok.EqualsAndHashCode;

@EqualsAndHashCode
public class MyClass {
    private String name;
    private int age;
}

위 코드에서 Lombok은 자동으로 equals 메서드를 생성하여 nameage 필드를 기반으로 두 객체의 동일성을 비교하게 됩니다.

2. Lombok에서 equals를 재정의해야 하는 경우

때때로 Lombok의 기본 동작을 수정하거나, 특정 필드만을 대상으로 equals 메서드를 정의하고 싶은 경우가 있습니다. Lombok의 @EqualsAndHashCode 어노테이션은 필드 선택 옵션을 제공하며, 이를 통해 특정 필드만 포함하거나 제외할 수 있습니다.

import lombok.EqualsAndHashCode;

@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class MyClass {
    @EqualsAndHashCode.Include
    private String name;
    
    @EqualsAndHashCode.Exclude
    private int age;
}

위 코드에서는 name 필드만 equalshashCode에 포함되며, age는 제외됩니다.

3. final로 막는다는 의미

final로 막는다는 것은 주로 클래스나 메서드가 상속되지 않도록 방지하거나, 재정의할 수 없도록 제한하는 것을 의미합니다.

  • 메서드에서 final: 메서드를 final로 선언하면 해당 메서드는 하위 클래스에서 재정의(overriding)할 수 없습니다. 즉, equals 메서드를 final로 선언하면, 해당 클래스를 상속한 다른 클래스가 이를 재정의할 수 없게 됩니다.

    @EqualsAndHashCode
    public class MyClass {
        private String name;
        
        @Override
        public final boolean equals(Object obj) {
            // 커스텀 equals 구현
        }
    }
  • 클래스에서 final: 클래스를 final로 선언하면, 그 클래스를 상속하는 다른 클래스를 만들 수 없습니다. 즉, 상속을 통한 equals 재정의를 막을 수 있습니다.

    public final class MyClass {
        private String name;
        
        // Lombok의 @EqualsAndHashCode를 이용해 생성된 equals 메서드
    }

4. final로 막아보자는 의미

주로 equalshashCode와 같은 메서드의 재정의를 막는 것은 객체의 동일성 비교 로직이 변하지 않도록 강제하려는 목적입니다. 만약 equals 메서드를 하위 클래스에서 재정의한다면, 동일 객체를 비교할 때 의도하지 않은 동작이 발생할 수 있습니다. 이를 방지하기 위해 final 키워드를 사용하여 메서드를 재정의하지 못하도록 막을 수 있습니다.

예를 들어, 데이터 무결성이나 비즈니스 로직에서 객체의 동일성 비교가 중요한 경우, equals 메서드를 final로 지정함으로써 이러한 재정의를 제한할 수 있습니다.

결론

  • Lombok을 사용하면 @EqualsAndHashCode로 자동으로 equals 메서드를 생성할 수 있지만, 특정 요구 사항이 있을 경우 이를 재정의할 수 있습니다.

  • final 키워드를 사용해 equalshashCode를 재정의할 수 없게 막음으로써 객체의 동일성 비교 로직이 일관되게 유지되도록 할 수 있습니다. 이 방법은 객체의 일관성과 무결성을 보장하려는 경우에 유용하게 사용됩니다.

4. @AutoValue는 lomback이 나온이후로 못한다..?

@AutoValue와 Lombok은 서로 다른 라이브러리이며, 둘 다 Java에서 보일러플레이트 코드(getter, setter, equals, hashCode 등)를 줄여주는 역할을 합니다. 하지만 Lombok이 등장한 이후에 @AutoValue를 사용할 수 없거나 못하는 것은 아닙니다. 두 라이브러리 모두 각각의 장단점이 있으며, 어느 것을 사용할지는 프로젝트 요구 사항과 개발자의 선호에 따라 달라집니다.

1. @AutoValue와 Lombok 비교

@AutoValue

  • 구글에서 제공하는 라이브러리로, 주로 불변 객체(Immutable Object) 생성을 지원합니다.

  • 주로 데이터 클래스(값 객체)에서 사용되며, 컴파일 타임에 필요한 코드를 자동으로 생성합니다.

  • 불변성을 보장하고, equals, hashCode, toString 등의 메서드를 자동으로 생성합니다.

    예:

    @AutoValue
    public abstract class Person {
        public abstract String name();
        public abstract int age();
    
        public static Person create(String name, int age) {
            return new AutoValue_Person(name, age);
        }
    }
    • @AutoValue는 생성자가 자동으로 만들어지며, 클래스의 불변성을 유지하는 데 유리합니다.

    • 불변성(Immutable)을 보장하는 데이터 객체에 주로 사용됩니다.

Lombok

  • Lombok은 더 많은 기능을 제공하며, @Getter, @Setter, @EqualsAndHashCode, @ToString 등 다양한 어노테이션을 통해 다양한 종류의 메서드들을 자동 생성할 수 있습니다.

  • Lombok은 불변 객체를 만들기 위한 @Value 어노테이션도 제공하며, @Builder, @AllArgsConstructor, @NoArgsConstructor 등의 어노테이션으로 더 많은 유연성을 제공합니다.

    예:

    @Data
    public class Person {
        private final String name;
        private final int age;
    }
    • @Data@Getter, @Setter, @EqualsAndHashCode, @ToString 등을 한 번에 적용합니다.

    • Lombok은 매우 유연하며, 불변성과 가변성 둘 다 처리할 수 있습니다.

2. 왜 @AutoValue가 Lombok보다 덜 사용되는가?

Lombok이 더 많이 사용되는 이유는 그 유연성과 광범위한 기능 덕분입니다. @AutoValue는 주로 불변 객체 생성에 특화되어 있고, Lombok은 다양한 목적에 더 적합하며 더 많은 기능을 제공합니다. 그럼에도 불구하고 @AutoValue는 여전히 불변 객체를 만들 때 강력한 도구입니다.

주요 차이점:

  1. Lombok의 유연성: Lombok은 불변 객체뿐만 아니라 가변 객체, 빌더 패턴, 리플렉션 기반 기능까지 지원하여 더 다양한 상황에 적용할 수 있습니다.

  2. 프로젝트 요구 사항: 불변성을 엄격하게 요구하는 경우 @AutoValue가 더 적합할 수 있으며, 가변 객체가 필요하거나 더 많은 유연성이 필요하다면 Lombok이 적합합니다.

3. "Lombok 이후에 AutoValue를 못한다"는 의미?

이 말은 Lombok이 더 유연하고 다양한 기능을 제공하기 때문에 많은 프로젝트에서 Lombok이 더 선호된다는 의미일 수 있습니다. Lombok을 도입한 프로젝트에서는 @AutoValue를 사용할 필요가 없을 수 있으며, 팀 내에서 하나의 라이브러리로 통일성을 유지하는 것이 일반적입니다. 그러나 이것이 @AutoValue를 사용할 수 없다는 의미는 아닙니다. Lombok이 나온 이후에도 @AutoValue는 여전히 사용 가능하며, 특정한 요구사항이나 상황에서는 적합할 수 있습니다.

결론

  • @AutoValue는 여전히 사용할 수 있으며, 특히 불변 객체를 생성하는 데 강력한 도구입니다.

  • Lombok은 더 많은 기능유연성을 제공하여 더 다양한 상황에서 사용할 수 있으며, 그래서 많은 프로젝트에서 Lombok이 선호됩니다.

  • 어느 것을 사용할지 선택하는 것은 프로젝트의 요구 사항개발자의 선호에 따라 달라집니다.

5. equals는 jvm내에 있는 객체만 가지고 판단해야 하는데 네트워크를 타게 되면.. 매번 달라지게 된다? ip에 따라 달라지게 된다고 한다..

6. 로그 추적을 위한 것

  • ELK

7. private로 했을 때.. 클라이언트가 값을 저장하고 받는 방법

음 나는 궁금한게 클라이언트가 변경하지 못하게 필드나 그런거에 private를 쓰래 근데 클라이언트가 값을 입력하면 받아서 또 저장하고 보여줘야 하잖아? 근데 그러면 private를 쓰면 그게 불가하지 않아?

좋은 질문입니다! private 접근 제어자를 사용하면 클래스 내부의 필드를 외부에서 직접 접근하지 못하도록 막을 수 있습니다. 하지만 이것이 클라이언트가 값을 입력하고 저장하고 보여주는 기능을 불가능하게 만드는 것은 아닙니다.

어떻게 가능한가요?

  1. 생성자나 메서드를 통해 값을 입력받습니다:

    • 생성자: 객체가 생성될 때 필요한 값을 받아서 private 필드에 저장합니다.

    • 메서드: public 메서드를 통해 외부에서 값을 입력받아 private 필드에 저장할 수 있습니다.

  2. 접근자(getter) 메서드를 통해 값을 반환합니다:

    • public 접근자를 가진 메서드를 제공하여 private 필드의 값을 외부에 노출할 수 있습니다. 이렇게 하면 필드의 값을 읽을 수 있지만, 직접 수정할 수는 없습니다.

예제 코드

아래는 User 클래스를 예로 들어 설명하겠습니다.

public final class User {

    // 필드를 private으로 선언하여 외부에서 직접 접근을 막음
    private final String name;
    private int age;

    // 생성자를 통해 초기 값 설정
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // public 메서드를 통해 값을 설정 (필요한 경우)
    public void setAge(int age) {
        if (age > 0) {
            this.age = age;
        } else {
            System.out.println("유효한 나이를 입력하세요.");
        }
    }

    // 접근자 메서드로 필드 값 반환
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    // 추가적인 메서드 작성 가능
    public void displayUserInfo() {
        System.out.println("이름: " + name + ", 나이: " + age);
    }
}

설명

  • private 필드:

    • nameage 필드를 private으로 선언하여 외부에서 직접 접근하거나 수정할 수 없도록 했습니다.

  • 생성자를 통한 초기화:

    • User 객체를 생성할 때 생성자를 통해 nameage 값을 입력받아 필드에 저장합니다.

  • public 메서드를 통한 값 설정:

    • setAge(int age) 메서드를 제공하여 **특정 조건(예: 나이가 0보다 큰지 확인)**을 만족하는 경우에만 age 필드의 값을 변경할 수 있도록 합니다.

  • 접근자 메서드:

    • getName()getAge() 메서드를 통해 private 필드의 값을 읽을 수 있습니다.

  • 정보 표시 메서드:

    • displayUserInfo() 메서드를 통해 사용자 정보를 출력할 수 있습니다.

사용 예시

public class Main {

    public static void main(String[] args) {
        // 객체 생성
        User user = new User("홍길동", 25);

        // 정보 출력
        user.displayUserInfo(); // 출력: 이름: 홍길동, 나이: 25

        // 나이 변경
        user.setAge(30);

        // 변경된 정보 출력
        user.displayUserInfo(); // 출력: 이름: 홍길동, 나이: 30

        // 필드에 직접 접근하려고 하면 오류 발생
        // user.age = 35; // 컴파일 오류: age has private access in User
    }
}

핵심 포인트

  • 캡슐화(Encapsulation):

    • private 필드를 사용함으로써 데이터를 보호하고, 클래스 내부 구현을 숨길 수 있습니다.

    • 외부에서는 제공된 public 메서드를 통해서만 필드에 접근하거나 수정할 수 있습니다.

  • 데이터 무결성 유지:

    • setAge() 메서드에서 나이가 0보다 큰지 확인하는 것처럼, 필드 값의 유효성을 검증할 수 있습니다.

    • 이를 통해 잘못된 데이터가 필드에 저장되는 것을 방지합니다.

  • 유지보수성 향상:

    • 내부 구현이 변경되더라도 외부에서 사용하는 인터페이스(public 메서드)는 그대로 유지될 수 있으므로, 코드의 변경이 외부에 미치는 영향을 최소화합니다.

결론

  • private 필드를 사용하면 클라이언트가 객체의 내부 상태를 직접 변경하지 못하게 막을 수 있지만, 생성자나 메서드를 통해 필요한 값을 입력받고 저장할 수 있습니다.

  • public 메서드를 통해 필드의 값을 제공하거나 수정할 수 있으므로, 클라이언트는 필요한 기능을 사용할 수 있습니다.

  • 이렇게 하면 데이터의 무결성을 유지하면서도 필요한 동작을 제공할 수 있습니다.

근데 setter도 안쓰는게 좋다고 했는데 user.setAge는 생성자라 괜찮은거야? 생성자는 불변으로 안 만들어도 돼?

좋은 질문입니다! **불변 클래스(Immutable Class)**를 만들 때 setter 메서드를 제공하지 않는 것이 좋다고 말씀드렸습니다. 이는 객체의 상태를 변경할 수 없도록 하기 위함입니다. 하지만 생성자는 객체를 생성할 때 필수적인 요소이며, 객체의 초기 상태를 설정하는 역할을 합니다.

Setter와 생성자의 차이점

  • Setter 메서드:

    • 객체가 생성된 이후에 필드의 값을 변경할 수 있게 합니다.

    • 불변 클래스에서는 제공하지 않는 것이 좋습니다. 왜냐하면 객체의 상태가 변경되면 불변성이 깨지기 때문입니다.

  • 생성자:

    • 객체가 생성될 때 한 번만 호출되며, 필드의 초기값을 설정합니다.

    • 불변 클래스에서도 필수적으로 사용됩니다. 생성자를 통해 초기 상태를 설정한 후에는 상태를 변경할 수 없도록 합니다.

코드 예시로 설명

당신이 제공한 코드에서 user.setAge(30);setter 메서드를 호출하여 age 필드의 값을 변경하고 있습니다. 이는 객체의 상태를 변경하므로 불변 클래스의 원칙에 위배됩니다.

public class User {

    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 이 메서드는 객체의 상태를 변경하므로 불변성을 깨뜨립니다.
    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public void displayUserInfo() {
        System.out.println("이름: " + name + ", 나이: " + age);
    }
}

위 코드에서 setAge 메서드는 객체의 상태를 변경하므로 불변 클래스에서는 제거해야 합니다.

불변 클래스로 수정하기

불변 클래스를 만들기 위해서는 다음과 같이 수정해야 합니다:

  1. 필드를 private final로 선언하여 객체 생성 후에는 값이 변경되지 않도록 합니다.

  2. Setter 메서드를 제거하여 외부에서 객체의 상태를 변경할 수 없게 합니다.

  3. 생성자를 통해 필드 초기화를 진행합니다.

public final class User {

    private final String name;
    private final int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Getter 메서드만 제공
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public void displayUserInfo() {
        System.out.println("이름: " + name + ", 나이: " + age);
    }
}

이렇게 수정하면 User 클래스는 불변 클래스가 됩니다. 이제 객체를 생성한 후에는 상태를 변경할 수 없으며, 클라이언트가 필드에 직접 접근하거나 수정할 수 없습니다.

사용 예시

public class Main {

    public static void main(String[] args) {
        User user = new User("홍길동", 25);

        user.displayUserInfo(); // 출력: 이름: 홍길동, 나이: 25

        // 아래 코드는 컴파일 오류를 발생시킴
        // user.setAge(30); // 오류: setAge(int)은 User 클래스에서 정의되지 않음

        // 새로운 객체를 생성하여 값 변경
        User updatedUser = new User("홍길동", 30);
        updatedUser.displayUserInfo(); // 출력: 이름: 홍길동, 나이: 30
    }
}

생성자는 불변 클래스로 만들 수 있을까?

  • 생성자 자체는 객체를 생성할 때 한 번만 호출되며, 필드의 초기값을 설정하는 역할을 합니다.

  • 생성자 내부에서 필드를 변경하는 것은 객체의 초기 상태를 설정하는 것이므로 불변성에 위배되지 않습니다.

  • 따라서 생성자는 불변 클래스에서도 필수적이며, 불변 클래스를 만드는 데 문제가 되지 않습니다.

불변 클래스에서의 값 변경은 어떻게 하나요?

불변 클래스에서는 객체의 상태를 변경할 수 없기 때문에, 새로운 객체를 생성하여 변경된 값을 갖도록 합니다.

예시: 나이를 변경하고 싶은 경우

User user = new User("홍길동", 25);

// 나이를 30으로 변경하고 싶을 때 새로운 객체 생성
User updatedUser = new User(user.getName(), 30);

user.displayUserInfo();         // 출력: 이름: 홍길동, 나이: 25
updatedUser.displayUserInfo();  // 출력: 이름: 홍길동, 나이: 30

불변 클래스의 장점 다시 정리

  • 안전성: 객체의 상태가 변경되지 않으므로 예기치 않은 변경으로 인한 오류를 방지할 수 있습니다.

  • 스레드 안전성: 여러 스레드에서 동시에 객체를 사용해도 상태가 변경되지 않으므로 안전합니다.

  • 단순성: 객체의 상태 변화에 따른 복잡한 로직을 관리할 필요가 없습니다.

결론

  • Setter 메서드는 불변 클래스에서 제공하지 않는 것이 좋습니다. 객체의 상태를 변경할 수 없도록 하기 위해서입니다.

  • 생성자는 객체의 초기 상태를 설정하는 역할을 하며, 불변 클래스에서도 필요합니다.

  • 필드를 private final로 선언하고, 생성자를 통해 초기화하며, Setter 메서드를 제거하면 불변 클래스를 만들 수 있습니다.

  • 값의 변경이 필요할 때는 새로운 객체를 생성하여 사용하면 됩니다.

8. 그렇다면 스프링에서의 불변 클래스

스프링 프레임워크에서의 불변 클래스 사용

스프링(Spring) 프레임워크는 자바 개발에서 널리 사용되는 애플리케이션 프레임워크로, 주로 **의존성 주입(Dependency Injection)**과 **제어의 역전(Inversion of Control)**을 통해 객체의 생성과 관리를 담당합니다. 스프링에서 객체의 상태 관리와 불변 클래스의 사용에 대해 궁금하신 것 같습니다. 아래에서는 스프링에서 불변 클래스를 어떻게 활용하고, 어떤 상황에서 주의해야 하는지 설명해 드리겠습니다.


1. 스프링과 자바빈 규약

스프링은 전통적으로 자바빈(JavaBean) 규약을 따르는 것을 권장합니다. 자바빈은 다음과 같은 특징을 갖습니다:

  • 기본 생성자 제공: 파라미터가 없는 public 기본 생성자.

  • 프라이빗 필드와 공개된 Getter/Setter: 필드는 private으로 선언하고, public 접근자와 변경자를 제공합니다.

이러한 규약은 스프링이 객체를 생성하고 관리하는 데 용이하게 만들어줍니다. 특히, 스프링의 데이터 바인딩이나 프로퍼티 설정 기능을 사용할 때 유용합니다.


2. 불변 클래스와 스프링

불변 클래스를 사용하면 객체의 상태가 변경되지 않아 여러 가지 장점을 얻을 수 있지만, 스프링에서는 다음과 같은 고려사항이 있습니다:

  • 의존성 주입과 생성자 사용: 스프링은 의존성 주입 시 **생성자 주입(Constructor Injection)**을 지원합니다. 이를 통해 불변 클래스의 필드를 설정할 수 있습니다.

  • Setter를 사용하지 않는 방식: 생성자 주입을 사용하면 Setter 메서드가 없어도 됩니다.

예시: 불변 클래스를 스프링 빈으로 등록하기

@Component
public class UserService {

    private final UserRepository userRepository;

    // 생성자 주입을 통해 의존성 주입
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // 비즈니스 로직 메서드
}
  • UserServiceuserRepository를 생성자를 통해 주입받으며, 필드는 private final로 선언되어 있습니다.

  • 스프링은 생성자를 통해 의존성을 주입할 수 있으므로 Setter가 필요 없습니다.


3. 스프링 데이터와 엔티티 불변성

스프링 데이터 JPA를 사용할 때는 엔티티 클래스를 정의해야 합니다. 여기서는 몇 가지 주의할 점이 있습니다:

  • JPA 엔티티는 기본 생성자가 필요합니다. 이는 프록시를 생성하거나 조회 시 객체를 생성하기 위해 필요합니다.

  • 필드는 프라이빗으로 선언하고 Getter/Setter를 제공하는 것이 일반적입니다.

  • 불변성을 유지하려면:

    • 필드를 final로 선언하지 못하는 경우가 많습니다.

    • Setter를 제공하지 않고, 필요할 경우 접근 수준을 protected로 낮추어 사용할 수 있습니다.

예시: 불변 엔티티 클래스

@Entity
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    // 기본 생성자: JPA 구현체가 사용 (접근 제한자를 protected로 설정)
    protected User() {
    }

    // 생성자를 통해 필드 초기화
    public User(String name) {
        this.name = name;
    }

    // Getter만 제공
    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    // Setter를 제공하지 않음으로써 불변성 유지
}
  • 주의점: JPA 엔티티의 경우 프록시 생성을 위해 기본 생성자가 필요하며, 이는 protected로 선언하여 외부에서 사용되지 않도록 합니다.

  • Setter를 제공하지 않음으로써 엔티티의 불변성을 어느 정도 유지할 수 있습니다.

  • 하지만 필드를 final로 선언할 수는 없으므로 완전한 불변성을 달성하기는 어렵습니다.


4. DTO(Data Transfer Object)와 불변성

스프링 애플리케이션에서 DTO는 계층 간 데이터 전달을 위해 사용됩니다. DTO를 불변 클래스로 만들면 다음과 같은 이점이 있습니다:

  • 안전한 데이터 전달: 객체의 상태가 변경되지 않아 예기치 않은 수정으로부터 보호됩니다.

  • 직렬화와 역직렬화: JSON 등으로 데이터를 주고받을 때 불변성을 유지할 수 있습니다.

예시: 불변 DTO 클래스

public final class UserDto {

    private final String name;
    private final int age;

    @JsonCreator
    public UserDto(@JsonProperty("name") String name, @JsonProperty("age") int age) {
        this.name = name;
        this.age = age;
    }

    // Getter만 제공
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}
  • Lombok 라이브러리를 사용하면 불변 DTO를 더 쉽게 생성할 수 있습니다.

Lombok을 사용한 불변 DTO

import lombok.Value;

@Value
public class UserDto {
    String name;
    int age;
}
  • @Value 어노테이션은 모든 필드를 private final로 선언하고, Getter를 생성하며, 생성자를 만들어 줍니다.


5. ConfigurationProperties와 불변성

스프링에서는 애플리케이션 설정 값을 바인딩하기 위해 @ConfigurationProperties를 사용합니다. 이때도 불변 클래스를 사용할 수 있습니다.

예시: 불변 설정 클래스

@Configuration
@EnableConfigurationProperties(AppProperties.class)
public class AppConfig {
}

@ConfigurationProperties(prefix = "app")
public class AppProperties {

    private final String name;
    private final String version;

    public AppProperties(String name, String version) {
        this.name = name;
        this.version = version;
    }

    // Getter만 제공
    public String getName() {
        return name;
    }

    public String getVersion() {
        return version;
    }
}
  • 그러나 @ConfigurationProperties는 기본적으로 Setter를 통해 프로퍼티를 주입하기 때문에, 불변 클래스를 사용하려면 Constructor Binding을 활성화해야 합니다.

  • 스프링 부트 2.2 이상에서는 @ConstructorBinding을 사용하여 생성자 바인딩을 할 수 있습니다.

생성자 바인딩 예시

@ConfigurationProperties(prefix = "app")
@ConstructorBinding
public class AppProperties {

    private final String name;
    private final String version;

    public AppProperties(String name, String version) {
        this.name = name;
        this.version = version;
    }

    // Getter만 제공
}
  • 이렇게 하면 프로퍼티 값이 생성자를 통해 주입되며, 불변성을 유지할 수 있습니다.


6. 결론 및 권장 사항

  • 스프링에서 불변 클래스를 사용할 수 있으며, 생성자 주입과 Getter를 활용하여 불변성을 유지할 수 있습니다.

  • JPA 엔티티나 일부 스프링 기능에서는 불변성을 완벽히 유지하기 어려울 수 있지만, 가능한 범위 내에서 불변성을 지키는 것이 좋습니다.

  • Setter 메서드를 제공하지 않고, 필요하다면 생성자나 정적 팩토리 메서드를 통해 객체를 생성합니다.

  • Lombok이나 Immutable과 같은 라이브러리를 활용하면 불변 클래스를 더 쉽게 만들 수 있습니다.

  • 불변성을 유지하면 멀티스레드 환경에서의 안전성, 예측 가능한 코드 작성, 유지보수성 향상 등의 이점을 얻을 수 있습니다.


9. 레이스 컨디션

레이스 컨디션(Race Condition)이란 무엇인가요?

레이스 컨디션은 멀티스레드 또는 멀티프로세스 환경에서 두 개 이상의 스레드나 프로세스가 동시에 공유 자원에 접근하거나 조작하려고 할 때 발생하는 문제를 말합니다. 이로 인해 프로그램의 실행 결과가 의도치 않게 달라지거나 예기치 않은 버그가 발생할 수 있습니다.

왜 발생하나요?

  • 동기화 부족: 공유 자원에 대한 접근이 적절히 동기화되지 않으면, 여러 스레드가 동시에 자원에 접근하여 상태를 변경할 수 있습니다.

  • 비결정적 실행 순서: 스레드의 실행 순서는 운영체제의 스케줄링에 따라 결정되며, 예측할 수 없습니다. 이로 인해 실행 결과가 매번 달라질 수 있습니다.

예시

간단한 예로, 두 개의 스레드가 하나의 변수 counter를 증가시키는 작업을 수행한다고 가정해보겠습니다.

public class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getCounter() {
        return counter;
    }
}

두 스레드가 동시에 increment() 메서드를 호출하면 다음과 같은 문제가 발생할 수 있습니다:

  1. 스레드 A가 counter의 값을 읽음 (counter = 0)

  2. 스레드 B가 counter의 값을 읽음 (counter = 0)

  3. 스레드 A가 counter를 1 증가시킴 (counter = 1)

  4. 스레드 B가 counter를 1 증가시킴 (counter = 1)

이 경우, 두 번 증가시켰지만 counter의 값은 1이 됩니다. 의도한 결과는 2여야 하지만, 레이스 컨디션으로 인해 올바르지 않은 결과가 나왔습니다.

해결 방법

  • 동기화 사용: synchronized 키워드나 Lock 객체를 사용하여 공유 자원에 대한 접근을 직렬화합니다.

    public class Counter {
        private int counter = 0;
    
        public synchronized void increment() {
            counter++;
        }
    
        public synchronized int getCounter() {
            return counter;
        }
    }
  • 원자적 연산 사용: AtomicInteger와 같은 원자적 클래스를 사용하여 동기화 없이도 안전하게 연산할 수 있습니다.

    import java.util.concurrent.atomic.AtomicInteger;
    
    public class Counter {
        private AtomicInteger counter = new AtomicInteger(0);
    
        public void increment() {
            counter.incrementAndGet();
        }
    
        public int getCounter() {
            return counter.get();
        }
    }
  • 불변 객체 사용: 객체의 상태를 변경하지 않고 새로운 객체를 생성하여 반환하는 불변 객체 패턴을 사용하면 레이스 컨디션을 방지할 수 있습니다.

결론

레이스 컨디션은 멀티스레드 환경에서 매우 흔하게 발생하는 문제로, 프로그램의 신뢰성과 안정성을 해칠 수 있습니다. 따라서 공유 자원에 대한 접근은 반드시 적절한 동기화 메커니즘을 사용하여 제어해야 합니다.

10. 자기사용이란 뭐고 왜 문제가 될까? - item 18

자기사용(self-use)이란 무엇인가요?

**자기사용(self-use)**은 객체 지향 프로그래밍에서 클래스 내의 메서드가 같은 클래스의 다른 메서드를 호출하여 기능을 구현하는 것을 의미합니다. 즉, 클래스의 메서드들이 서로를 호출하여 동작을 수행하는 것을 말합니다.

예시로 이해하기

예를 들어, HashSet 클래스에서 addAll() 메서드가 add() 메서드를 사용하여 구현되어 있다면, 이는 addAll() 메서드가 add() 메서드를 자기사용하고 있다고 말할 수 있습니다.

public class HashSet<E> implements Set<E> {
    // ...

    public boolean addAll(Collection<? extends E> c) {
        boolean modified = false;
        for (E e : c) {
            if (add(e)) { // 여기서 add() 메서드를 호출합니다.
                modified = true;
            }
        }
        return modified;
    }

    public boolean add(E e) {
        // 원소를 추가하는 로직
    }

    // ...
}

위 코드에서 보듯이, addAll() 메서드는 내부적으로 add() 메서드를 호출하여 컬렉션의 각 원소를 추가합니다. 이는 addAll()add()자기사용하고 있는 것입니다.

왜 자기사용이 문제가 될까요?

자기사용은 클래스의 내부 구현 방식에 해당하며, 외부에 공개되지 않은 구현 세부사항입니다. 따라서 자식 클래스가 부모 클래스의 자기사용 여부에 의존하여 동작을 구현하면 다음과 같은 문제가 발생할 수 있습니다.

  1. 내부 구현 변경 시 문제 발생:

    • 부모 클래스의 내부 구현이 변경되면 자식 클래스의 동작이 의도치 않게 변할 수 있습니다.

    • 예를 들어, 다음 버전에서 HashSetaddAll()이 더 이상 add()를 사용하지 않고 직접 원소를 추가하도록 구현이 변경된다면, 자식 클래스에서 add()를 재정의하여 추가적인 기능을 구현한 부분이 더 이상 동작하지 않을 수 있습니다.

  2. 상위 클래스의 사양에 없는 동작에 의존:

    • HashSet의 공식 문서나 API 사양에는 addAll()add()를 사용하여 구현된다는 내용이 없습니다.

    • 즉, 이는 구현 세부사항이며, 외부에서 알 수 없고 의존해서도 안 되는 부분입니다.

  3. 코드의 유지보수성 및 안정성 저하:

    • 상위 클래스의 내부 구현에 의존하면, 상위 클래스의 업데이트나 수정에 취약해집니다.

    • 이는 코드의 안정성을 저해하고, 예기치 않은 버그를 유발할 수 있습니다.

InstrumentedHashSet에서의 문제점

InstrumentedHashSet 클래스는 HashSet을 상속받아 원소가 추가된 횟수를 세기 위해 add()addAll() 메서드를 재정의했습니다.

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCount++; // 원소 추가 카운트 증가
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size(); // 추가되는 원소 수만큼 카운트 증가
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

문제 발생 상황:

  • addAll() 메서드를 통해 원소를 추가하면 addCount두 배로 증가합니다.

    • addAll()에서 addCount += c.size();로 증가.

    • super.addAll(c);를 호출하면 HashSetaddAll()이 내부적으로 add()를 호출.

    • add()는 재정의된 add() 메서드이므로 addCount++로 다시 증가.

원인:

  • InstrumentedHashSetHashSet의 **자기사용(self-use)**에 의존하여 동작하고 있기 때문입니다.

  • HashSetaddAll()add()를 사용한다는 구현 세부사항에 기반하여 코드를 작성했습니다.

  • 그러나 이러한 내부 구현은 언제든지 변경될 수 있으며, 변경 시 InstrumentedHashSet의 동작이 깨지게 됩니다.

자기사용에 의존하지 않는 설계 방법

**컴포지션과 위임(Delegation)**을 사용하여 상위 클래스의 내부 구현에 의존하지 않는 안전한 코드를 작성할 수 있습니다.

InstrumentedSet 구현 예시

public class InstrumentedSet<E> implements Set<E> {
    private final Set<E> set; // 실제 작업을 위임할 Set 객체
    private int addCount = 0;

    public InstrumentedSet(Set<E> set) {
        this.set = set;
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return set.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }

    // 나머지 메서드들은 set 객체에 위임
    // ...
}
  • 컴포지션 사용: InstrumentedSet 클래스는 Set 인터페이스를 구현하고, 내부에 다른 Set 인스턴스를 필드로 가집니다.

  • 위임 사용: 메서드들은 내부의 set 객체에 동작을 위임합니다.

  • 장점:

    • 상위 클래스의 내부 구현에 의존하지 않으므로, 상위 클래스의 변경에 영향을 받지 않습니다.

    • 코드의 안정성과 유지보수성이 향상됩니다.

요약

  • **자기사용(self-use)**은 클래스 내의 메서드가 다른 메서드를 호출하여 동작을 구현하는 것을 의미합니다.

  • 상속을 사용할 때 상위 클래스의 자기사용 여부에 의존하면 위험합니다.

    • 상위 클래스의 내부 구현은 언제든지 변경될 수 있으며, 이러한 변경은 하위 클래스의 동작을 깨뜨릴 수 있습니다.

  • 해결책:

    • 상속보다는 컴포지션과 위임을 사용하여 상위 클래스의 내부 구현에 의존하지 않고 기능을 확장합니다.

    • 이를 통해 코드의 안정성과 유연성을 높일 수 있습니다.

추가 예시: 자기사용의 문제를 보여주는 간단한 코드

public class Parent {
    public void methodA() {
        System.out.println("Parent.methodA()");
        methodB();
    }

    public void methodB() {
        System.out.println("Parent.methodB()");
    }
}

public class Child extends Parent {
    @Override
    public void methodB() {
        System.out.println("Child.methodB()");
    }
}

설명:

  • Parent 클래스의 methodA()methodB()를 호출합니다.

  • Child 클래스는 Parent를 상속하고 methodB()를 재정의합니다.

  • Child의 인스턴스로 methodA()를 호출하면 어떤 결과가 나올까요?

실행 코드:

Child child = new Child();
child.methodA();

출력 결과:

Parent.methodA()
Child.methodB()

분석:

  • ParentmethodA()에서 methodB()를 호출할 때, 재정의된 ChildmethodB()가 호출됩니다.

  • 이는 Parent 클래스의 자기사용 메서드 호출이 다른 클래스의 재정의된 메서드를 호출하게 되어 예상치 못한 동작을 유발할 수 있습니다.

이러한 이유로:

  • 상속을 사용할 때는 상위 클래스의 메서드들이 내부적으로 어떤 메서드를 호출하는지(자기사용 여부)를 고려해야 합니다.

  • 상위 클래스의 내부 구현에 의존하지 않고 기능을 확장하려면 컴포지션과 위임을 사용하는 것이 바람직합니다.

결론

자기사용(self-use)은 클래스의 내부 구현 방식이며, 외부에서는 알 수 없는 부분입니다. 상속을 통해 자식 클래스에서 부모 클래스의 자기사용에 의존하여 동작을 구현하면, 부모 클래스의 내부 구현 변경 시 자식 클래스의 동작이 깨질 수 있습니다. 따라서 상속보다는 컴포지션과 위임을 사용하여 안전하고 유연한 코드를 작성하는 것이 좋습니다.

Last updated