ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 객체지향 프로그래밍 입문 강의 (1) - 캡슐화
    et cetera/TIL 2021. 12. 27. 23:51
    반응형

    캡슐화(Encapsulation)

    - 데이터 + 데이터 관련 기능을 묶는 것.

    - 객체의 기능 구현을 외부에 감추는 것

    - 정보 은닉(Information Hiding)의 의미도 포함

    - 외부에 영향을 끼치지 않고 객체 내부 구현을 변경 가능

     

     

    캡슐화를 하지 않으면..

    if (acc.getMembership() == REGULAR && acc.getExpDate().isAfter(now()) {
        // 정회원 기능
    }

    여기서 5년 이상 사용자에게 일부 기능의 정회원 혜택을 1개월 무상 제공하는 이벤트를 열었다고 가정해보자.

    그리하려 코드를 다음과 같이 수정했다.

    if (acc.getMembership() == REGULAR &&
        (
            (acc.getServiceDate().isAfter(fiveYearAgo) && acc.getExpDate().isAfter(now())) ||
            (acc.getServiceDate().isBefore(fiveYearAgo) && addMonth(acc.getExpDate()).isAfter(now()))
        )
    ) {
        // 정회원 기능
    }

    즉, 요구사항의 변화가 데이터 구조/사용에 변화를 일으키게 되는데,

    이 때 해당 데이터를 사용하는 부분들에서 전부 코드 수정이 발생하게 된다.

     

    요구사항의 변경 예시

    - 장기 사용자에게 특정 기능 실행 권한을 연장

    - 계정이 차단된 사용자에게는 실행 권한이 하나도 주어지지 않음

     

     

    캡슐화를 하면...

    기능을 제공하되, 구현 상세를 캡슐 내부로 감춘다.

    public class Account {
        private Membership membership;
        private Date expDate;
    
        public boolean hasRegularPermission() {
            return membership == REGULAR &&
                expDate.isAfter(now())
        }
        // ...
    }
    
    // 사용처
    if (acc.hasRegularPermission()) {
       // 정회원 기능
    }

    만약, 위와 동일하게 이벤트를 위해 코드 변경이 발생한다고 해도, 사용처의 코드는 전혀 바뀌지 않을 수 있다.

    public class Account {
        private Membership membership;
        private Date expDate;
        
        public boolean hasRegularPermission() {
            return membership == REGULAR &&
                // 코드 변경
                (expDate.isAfter(now()) ||
                    (
                        serviceDate.isBefore(fiveYearAgo()) &&
                        addMonth(expDate).isAfter(now())
                    )
                );
        }
        // ...
    }
    
    // 사용처
    if (acc.hasRegularPermission()) {
        // 정회원 기능
    }

     

    정리

    - 캡슐화: 기능의 구현을 외부로부터 감춤.

    - 캡슐화를 통해 기능을 사용하는 코드에 영향을 주지 않고 (또는 최소화)

    내부 구현을 변경할 수 있는 유연함을 가지게 된다.

     

    캡슐화는 연쇄적인 변경 전파를 최소화할 수 있게 해준다.

    - 요구사항의 변화가 Account 클래스의 내부 구현을 변경시켰지만

    Account 클래스에 캡슐화된 기능을 사용하는 곳의 코드에는 영향을 최소화

     

     

     

    캡슐화를 시도하면 코드 이해도가 상승한다

    if (acc.getMembership() == REGULAR) {
        // ...
    }

    위 코드의 캡슐화를 시도한다고 생각해보자.

    그러면 다음과 같은 사고의 과정이 필요하다.

    멤버십이 REGULAR와 같은지 검사하는 이유는 실제로 무엇 때문인가?
    ⇒ 검사하는 이유는 계정이 REGULAR 권한을 가졌는지를 확인하기 위함이구나
    ⇒ Account 클래스의 hasRegularPermission이라는 기능(REGULAR 권한인지 확인하는 기능)으로 캡슐화할 수 있겠다.

    즉, 캡슐화를 하려는 코드에 대한 이해가 뒷받침이 되어야 캡슐화가 가능하다.

    따라서 캡슐화를 위한 시도는 코드 이해도를 높이는 데에 도움을 준다.

     

     

     

     캡슐화를 위한 규칙

    Tell, Don't ask

    데이터를 달라고 하지 말고, 해달라고 하기.

    if (acc.getMembership == REGULAR) {
        // 정회원 기능
    }

    위 코드를 보면, Account로부터 membership 데이터를 가져와서 REGULAR인지 확인하고 있다.

    즉, 데이터를 달라고 해서, 데이터를 가져다가 판단하는 코드인 것.

     

    'Tell, Don't ask'란, 이렇게 하지말고 ‘데이터를 가져와서 내가 직접 하려는 일’을

    데이터를 가지고 있는 주체에게 시키라는 뜻이다.

    그래서 데이터를 요구하는 게 아니라, 다음과 같이

    ‘REGULAR 권한을 가진 계정인지 확인해주세요’ 라고 시키는 코드를 작성하라는 뜻.

    if (acc.hasRegularPermission()) {
        // 정회원 기능
    }

     

     

    Law of Demeter

    디메테르의 법칙 - “가까운 친구하고만 이야기해라”

    - 특정 메서드에서 직접 생성한 객체의 메서드만 호출

    - 특정 메서드의 파라미터로 받은 객체의 메서드만 호출

    - 객체의 필드로 참조하는 객체의 메서드만 호출

    public void someMethod() {
        acc.getExpDate().isAfter(now)
        // ...
    }

    위의 메서드는 다음과 같이 풀어쓸 수 있다.

    public void someMethod() {
        Date date = acc.getExpDate();
        date.isAfter(now);
        // ...
    }

    알고보니, Date라는 외부 객체의 메서드를 사용하고 있었다.

    acc의 메서드만 사용하는 것 같았는데 acc.getExpDate()의 결과로 Date 객체가 만들어졌고,

    그 결과 Date 객체의 메서드를 사용하게 되었던 것.

     

    해당 메서드에서 다른 객체와의 의존성이 생겨버린 것(결합도가 높아진 것)이다.

    결합도가 높아지면 특정 코드에 변경이 발생했을 때 연쇄적인 변경 전파가 생길 가능성이 높아진다.

     

    그래서 캡슐화를 통해 기능 구현을 숨기면 다음과 같이 코드를 개선시킬 수 있다.

    (acc 객체의 특정 메서드를 호출하는 형식으로 변경)

    public void someMethod() {
        acc.isExpired()
        // ...
    }

     

    즉, acc(Account)라는게 해당 메서드 내부에서 생성한 객체이건, 파라미터로 받은 객체이건,

    혹은 필드로 참조하고 있던 객체이건 무엇이 됐건 간에 해당 객체의 메서드만 사용하라는 것.

    그것이 바로 디메테르의 법칙이라고 할 수 있겠다.

     

     

     

    캡슐화 연습

    1번 예제

    public AuthResult authenticate(String id, String pw) {
        Member mem = findOne(id);
        if (mem == null) return AuthResult.NO_MATCH;
    
        if (mem.getVerificationEmailStatus() != 2) { // 1
            return AuthResult.NO_EMAIL_VERIFIED;
        }
    
        if (passwordEncoder.isPasswordValid(mem.getPassword(), pw, mem.getId())) {
            return AuthResult.SUCCESS;
        }
        
        return AuthResult.NO_MATCH;
    }

    1번으로 주석 표시된 부분이 'Tell, Don't ask' 법칙에 어긋난다.

    mem 객체에서 데이터를 받아와서 직접 판단하고 있는 모습을 볼 수 있다.

    이 판단 자체를 Member 객체로 넘겨서, Member 객체가 제공하는 기능으로 캡슐화할 수 있다.

    public class Member {
        private int verificationEmailStatus;
    
        public boolean isEmailVerified() {
            return verificationEmailStatus == 2;
        }
        // ...
    }
    
    public AuthResult authenticate(String id, String pw) {
        Member mem = findOne(id);
        if (mem == null) return AuthResult.NO_MATCH;
    
        if (mem.isEmailVerified()) { // GOOD
            return AuthResult.NO_EMAIL_VERIFIED;
        }
    
        if (passwordEncoder.isPasswordValid(mem.getPassword(), pw, mem.getId())) {
            return AuthResult.SUCCESS;
        }
        
        return AuthResult.NO_MATCH;
    }

    이렇게 하면 isEmailVerified 메서드의 내부 구현을 바꾸더라도,

    해당 메서드를 사용하는 곳의 코드는 변경하지 않아도 된다.

     

    2번 예제

    public class Rental {
        private Movie movie; // 대여할 영화
        private int daysRented; // 대여 기간
    
        public int getFrequentRenterPoints() {
            if (movie.getPriceCode() == Moive.NEW_RELEASE && // 1
                daysRented > 1)
                return 2;
            else 
                return 1;
        }
        // ...
    }
    
    public class Movie {
        public static int REGULAR = 0;
        public static int NEW_RELEASE = 1;
        private int priceCode;
       
        public int getPriceCode() {
            return priceCode;
        }
        // ...
    }

    1번 표시된 주석 부분을 보면, priceCode라는 데이터를 가져와서 그 값이 NEW_RELEASE인지 직접 판단하고 있다.

    물론, 소극적으로 캡슐화를 한다면, 1번 부분을 다음과 같이 변경할 수도 있을 것이다.

    if (movie.isNewRelease() && daysRented > 1) {

    하지만 별반 달라진 게 없다.

    좀 더 적극적으로, renterPoint를 계산하는 로직 전체를 Movie 클래스에 캡슐화시켜보자.

    public class Rental {
        private Movie movie;
        private int daysRented;
    
        public int getFrequentRenterPoints() {
            return movie.getFrequentRenterPoints(daysRented); // 뿅
        }
        // ...
    }
    
    public class Movie {
        public static int REGULAR = 0;
        public static int NEW_RELEASE = 1;
        private int priceCode;
       
        public int getFrequentRenterPoints(int daysRented) {
            if (priceCode == NEW_RELEASE && daysRented > 1) return 2;
            else return 1;
        }
        // ...
    }

    이렇게 하면, 이제 renterPoint를 구하는 로직이 바뀌더라도 Movie 클래스의 코드만 수정해주면 될 것이다.

     

    3번 예제

    public class Timer {
        public long startTime;
        public long stopTime;
    }
    
    Timer t = new Timer();
    t.startTime = System.currentTimeMillis(); // 1
    // ...
    t.stopTime = System.currentTimeMillis(); // 2
    long elapsedTime = t.stopTime - t.startTime; // 3

    세 군데 모두 Timer 객체의 데이터를 직접 사용하고 있다.

    1번은 타이머를 시작하는 코드, 2번은 타이머를 멈추는 코드.

    3번은 타이머를 시작하고 중지하기까지 얼마 만큼의 시간이 흘렀는지를 구하는 코드.

    매우 절차지향적인 코드라고 볼 수 있다.

    이는 다음과 같이 캡슐화해볼 수 있겠다.

    public class Timer {
        private long startTime;
        private long stopTime;
    
        public void start() {
            this.startTime = System.currentTimeMillis();
        }
    
        public void stop() {
            this.stopTime = System.currentTimeMillis();
        }
    
        public long elapsedTime(TimeUnit unit) {
            switch(unit) {
                case MILLISECOND:
                    return stopTime - startTime;
                // NANOSECOND일 때 로직도 추가 가능
                // ...
        }
    
    
    Timer t = new Timer();
    t.start();
    // ...
    t.stop();
    long time = t.elapsedTime(MILLISECOND);

    만약 타이머에 밀리초가 아니라, 나노초까지 더 세밀하게 시간을 구하는 기능이 추가된다고 하더라도

    타이머를 사용하는 곳의 코드는 변하지 않을 것이다.

     

    4번 예제

    public verifyEmail(String token) {
        Member mem = findByToken(token);
        if (mem == null) throw new BadTokenException();
    
        if (mem.getVerificationEmailStatus() == 2) { // 1
            throw new AlreadyVerifiedException();
        } else {
            mem.setVerificationEmailStatus(2); // 2
        }
        // ... 수정사항 DB 반영
    }

    1번의 코드를 isVerifiedEmail() 이라는 메서드로 캡슐화할 수도 있겠지만,

    전체적으로 크게 달라지지 않은, 뭔가 아쉬운 추상화이다.

    조금 더 생각해보자.

     

    1번에서는 데이터를 가져와서 직접 판단하고 있고, 2번에서는 데이터를 바꿔주고 있다.

    이렇게, 데이터를 직접 가져와서 무언가를 판단하고 그 결과로 데이터를 바꿔주는 코드는

    통으로 캡슐화를 시도해볼 수 있다.

    public class Member {
        private int verificationEmailStatus;
    
        public void verifyEmail() { // 뿅
            if (isEmailVerified())
                throw new AlreadyVerifiedException();
            else
                this.verificationEmailStatus = 2;
        }
    
        public boolean isEmailVerified() {
            return verificationEmailStatus == 2;
        }
    }
    
    public verifyEmail(String token) {
        Member mem = findByToken(token);
        if (mem == null) throw new BadTokenException();
    
        mem.verifyEmail(); // 뿅
        // ... 수정사항 DB 반영
    }

     

     

     

    출처

    인프런 '객체 지향 프로그래밍 입문' 강의 - 최범균

    반응형

    댓글

Designed by Tistory.