Effective Java : 아이템10. 완벽공략

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다. 


    완벽 공략 24. Value 기반의 클래스

    • 값 객체는 클래스처럼 생겼지만, 실제로는 int처럼 동작하는 클래스를 의미한다.
    • 값 객체는 다음 특징을 가진다.
      • 식별자가 없고 불변 객체다.
      • 인스턴스가 가지고 있는 필드 값을 기반으로 equals, hashCode, toString을 구현. (동일한지 판단)
      •  == 오퍼레이션이 아니라 equals를 사용해서 동등성을 비교해야 함. ==은 주소를 비교하기 때문임. 
      • 동일한 객체는 상호교환 가능하다.

     

    값 객체에는 식별자가 존재하면 안된다. 식별자는 같은 객체로 인식도 되는지를 알려주는 녀석인데, 값 객체는 값이 서로 같으면 같은 객체로 판단하기 때문에 식별자가 있으면 안된다. 값 객체의 동일성은 오로지 논리적 동치성(같은 값을 가졌는지)만 판단한다. 예를 들면 아래 PhoneNumber 클래스가 있다. PhoneNumber 클래스는 값 객체로 만들어졌으며, 가지고 있는 areaCode, prefix, lineNum이 같을 때만 같은 객체로 판단한다. 

    public final class PhoneNumber {
        private final short areaCode;
    	private final short prefix;
        private final short lineNum;
        
        @Override public boolean equals(Object o) {
            ...
            return o.lineNum == lineNum && o.prefix == prefix
                    && o.areaCode == areaCode;
        }
    
    }

    값 객체를 만드는 방법은 다음이 존재한다.

    1. record 사용 (자바 17이후)
    2. 필드에 final 키워드를 써서 불변 객체로 만들고, equals + toString을 구현. → Point 클래스

    예시를 하나 들어보자. Point 클래스처럼 값 객체를 만들 때, 식별자(Entity의 id 같은 것)가 있으면 안된다. 식별자는 객체를 식별하는 필드인데, 값 객체는 식별자가 아니라 값 객체가 가지는 필드의 값으로만 같은 객체인지 비교한다. 

    만약 Point 클래스에 x / y / id라는 필드가 있다고 가정해보자. 만약 (1,1,1) (1,1,2) 라는 값을 가진 포인트가 2개 있다면, 다른 객체로 인식할 것이다. 그러나 두 객체는 (1,1)이라는 값을 가진 객체들이기 때문에 서로 같은 객체라고 볼 수 있다. 이런 이유 때문에 값 객체를 사용할 때 식별자를 사용하지 말라는 것이다. 

    public class Point {
    
        private final int x;
        private final int y;
    
        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
    
            if (!(o instanceof Point)) {
                return false;
            }
    
            Point p = (Point) o;
            return p.x == x && p.y == y;
        }
    
        @Override
        public int hashCode()  {
            return 31 * x + y;
        }
    }

    완벽 공략 25. StackOverFlowError (로컬 변수와 객체가 저장되는 공간의 이름은?)

    • StackOverFlow Error는?
      • 더 이상 Stack 메모리에 쌓을 수 있는 공간이 없을 때 발생한다. 
    • Stack / Heap 메모리를 알아야 함.
      • Heap : 객체가 저장되는 메모리 공간
      • Stack : 메서드가 호출되었을 때의 로컬 정보가 저장되는 공간
    • 메서드 호출 시 동작
      • 메서드가 호출되면 스택 메모리에 스택 프레임이 쌓임.
      • 스택 프레임 들어있는 정보
        • 메서드에 전달하는 매개변수
        • 메서드 실행 끝나고 돌아갈 곳
        • Heap 메모리에 있는 객체에 대한 Reference
      • 스택에 스택 프레임을 더 이상 쌓을 수 없다면, StackOverFlowError가 발생함. 
      • 스택의 사이즈를 조정하고 싶다면? - Xss1M

     

    스텍 메모리는 한 쓰레드마다 사용하는 공간이다. 스택 메모리에는 스택 프레임이 차곡차곡 쌓이는데, 이 스택 프레임은 쓰레드의 메서드가 호출될 때 마다 벽돌처럼 쌓이고 호출이 끝나면 없어진다. 스택 프레임 공간에는 다음 정보가 들어있다.

    • 메서드에 전달하는 매개변수
    • 메서드에서 참조하는 객체들의 레퍼런스
    • 메서드 실행이 끝난 후 돌아갈 곳 

    스택 메모리에 스택 프레임이 쌓이다보면, 스택에 쌓일 수 있는 스택 프레임의 한계보다 더 쌓이는 경우가 생길 수 있다.  그게 바로 스택 오버 플로우 에러다. 

    스택 메모리와 힙 메모리는 조금 다르게 동작한다. 스택 메모리 공간은 메서드 호출이 완료되면, 스택 프레임이 삭제되면서 비워진다. 반면 힙 메모리는 GC가 정리해준다. 쉽게 이야기 하면, 힙 안에 실제 인스턴스가 존재하면 스택 메모리에 쌓이는 레퍼런스는, 힙 메모리에 존재하는 인스턴스를 가리키고 있는 것들이다. StackOverFlow Error는 아래 예시에서 발생할 수 있다.

     

    public static void hello1() {
        hello2();
    }
    public static void hello2() {
        hello1();
    }
    
    public static void main(String[] args) {
        hello1();
    }

    위 코드처럼 서로가 서로를 계속 호출할 경우, 스택 프레임이 계속 쌓이게 되어서 스택오버플로우가 발생한다. 이런 이유 때문에 순환참조가 위험하다고 하는 것일지도 모르겠다. 


    완벽 공략 26. 리스코프 치환 원칙 (객체 지향 5대 원칙 SOLID 중 하나)

    • 1994년, 바바라 리스코프의 논문 'A Behavioral Notion of Subtyping"에서 기원한 객체 지향 원칙
    • 리스코프 치환 원칙
      • '하위 클래스의 객체'가 '상위 클래스 객체'를 대체하더라도 소프트웨어의 기능을 깨뜨리지 않아야 한다.
      • 이 말은 상위 클래스 / 하위 클래스의 컨텍스트가 바뀌지 않아야 하는 것을 의미한다.
      • 예를 들어 요금을 계산하는 것이면 하위 클래스는 요금을 계산하되, 프리미엄을 조금 더 붙인 요금이 계산되어야 한다. 뜬금없이 직원 수를 반환하면 문맥을 깨는 것이다. 
    • 새로운 하위 클래스를 만든다면, 상위 클래스의 의미를 깨지 않는 선에서 구현한다. 상위 클래스에서도 이런 부분을 충분히 고민하고 구현해야 한다. 

    리스코프 치환 원칙은 객체 지향 5대 원칙 중 SOLID중 'L'을 담당한다.  SOLID는 다음을 의미한다. 

    • S : 단일 책임 원칙. 클래스 하나는 한 가지 이유로만 변경되어야 한다. 여러 이유로 클래스가 매번 변경되면, 클래스가 하는 일이 많기 때문에 리팩토링을 통해서 클래스 / 메서드 분리가 필요하다. 
    • O : 변경에는 닫혀있고, 확장에는 열려있어야 함. 
    • L : 리스코프 치환 원칙. 상위 클래스 타입의 인스턴스를 사용하던 코드는 하위 클래스 타입의 인스턴스가 오더라도 동일하게 동작해야한다는 것이다. 문법적으로는 하위 클래스 아무거나 가져다 둘 수 있지만, 문법보다는 시멘틱이 중요하다. '의미'를 지켜야 한다는 것이다. 메서드에 fly(), swim()이 정의되어있다고 하면 어떤 하위 클래스가 오더라도 fly()는 날아야하고, swim() 헤엄쳐야 한다. fly()를 했는데 걸으면 안된다. 
    • I : 역할 마다 인터페이스를 잘게 나누는 것이다.
    • D : DI(Dependency Injection) 

    리스코프 치환 원칙이 위배된 예시를 한번 살펴보자. 아래 CounterPointTest의 main() 메서드를 실행하면, Point / CountPoint 객체를 생성해서 onUnitCircle() 메서드를 호출한다. 그런데 이 때 같은 값 객체 (1,0을 가진)인데 하나는 unitCircle에 존재하는 것으로 나오며, 다른 하나는 존재하지 않는 것으로 나온다. 왜 이런 문제가 발생한 것일까?

    public class CounterPointTest {
        // 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. (58쪽)
        private static final Set<Point> unitCircle = Set.of(
                new Point( 1,  0), new Point( 0,  1),
                new Point(-1,  0), new Point( 0, -1));
    
        public static boolean onUnitCircle(Point p) {
            return unitCircle.contains(p);
        }
    
        public static void main(String[] args) {
            Point p1 = new Point(1,  0);
            Point p2 = new CounterPoint(1, 0);
    
            // true를 출력한다.
            System.out.println(onUnitCircle(p1));
    
            // true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
            System.out.println(onUnitCircle(p2));
        }
    }

    결정적으로는 리스코프 치환 원칙을 위배하도록 equals()가 구현되어 있기 때문이다. 아래와 같이 Point 클래스의 equals() 메서드가 구현되어있다. 여기서 같은 Class가 아니면 False를 반환하도록 하는 부분이 문제다. 이 부분의 구현이 '리스코프 치환 원칙'을 위배하는 것이다. 

        // 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽)
    /*    @Override
        public boolean equals(Object o) {
            if (o == null || o.getClass() != getClass())
                return false;
            Point p = (Point) o;
            return p.x == x && p.y == y;
        }*/

    다시 한번 정리하면 리스코프 치환 원칙은 문법적으로 문제 없지만, 상위 / 하위 클래스의 Semantic(의미)가 서로 다르게 구현되었을 때를 의미한다. 만약 상속 구조를 사용한다면, 의미를 유지하는 선에서 하위 클래스의 행동을 재정의 해야한다. 

    댓글

    Designed by JB FACTORY