본문 바로가기

JAVA/OOP

객체지향

반응형

객체지향은 데이터 및 데이터와 관련된 프로시저를 객체라고 불리는 단위로 묶는다.
객체는 프로시저를 실행하는데 필요한 만큼의 데이터를 가지며, 객체들이 모여 프로그램을 구성한다.

각 객체는 자신만의 데이터와 프로시저를 갖는다. 객체는 자신만의 기능을 제공하며, 각 객체들은 서로 연결되어 다른 객체가 제공하는 기능을 사용할 수 있게 된다.
객체는 다른 객체에 기능을 제공하기위해 프로시저를 사용하는데, 이 때 프로시저는 자신이 속한 객체의 데이터에만 접근할 수 있으며, 다른 객체에 속한 데이터는 접근할 수 없다.
모든 프로시저가 데이터를 공유하는 절차 지향과 달리 객체 지향은 객체 별로 데이터와 프로시저를 알맞게 정의해야 하고, 프로그램의 규모가 작을 때에는 절차지향방식보다 복잡한 구조를 갖게된다.

하지만, 객체지향적으로 만든 코드에서는 객체의 데이터를 변경하더라도 해당 객체로만 변화가 집중되고 다른 객체에는 영향을 주지 않기 때문에, 요구사항의 변화가 발생했을 때 절차 지향방식보다 더 쉽게 변경할 수 있는 장점을 갖는다.

객체(Object)
객체지향의 가장 기본은 객체이다. 객체는 데이터와 그 데이터를 조작하는 프로시저(오퍼레이션, 메서드, 함수)로 구성된다. 이는 객체의 물리적은 특징일 뿐이다.
실제로 객체를 정의할 때 사용되는 것은 객체가 제공해야할 기능이며, 객체가 내부적으로 어떤 데이터를 갖고 있는 지로는 정의되지 않는다.

예) 소리크기제어 객체
소리크기를 제어하는 기능을 제공
소리크기증가, 소리크기 감소, 음소거

이 객체가 내부적으로 소리 크기를 어떤 데이터 타입 값으로 보관하는지는 중요하지 않다.
또한 실제로 어떻게 소리 크기를 증가시키거나, 감소시키는지 알수 없다.
단지, 소리 크기 제어 객체는 '소리크기증가', '소리 크기 감소', '음소거'라는 세 개의 기능을 제공한다는 것이 중요할 뿐이다.

인터페이스와 클래스
객체는 객체가 제공하는 기능으로 정의하는데, 보통 객체가 제공하는 기능을 오퍼레이션이라고 부른다.
객체는 어퍼레이션으로 정의된다.
객체가 제공하는 기능을 사용한다는 것은 결국 객체의 오퍼레이션을 사용한다는 의미가 된다.
그런데 객체가 제공하는 오퍼레이션을 사용할 수 있으려면, 그 오퍼레이션의 사용법을 알아야한다.

예) 소리크기제어 객체의 소리크기 증가 기능을 제공하는 오퍼레이션을 사용하려면 이 오퍼레이션의 사용법을 알아야 한다.
오퍼레이션의 사용법은 일반적으로 3개로 구성되며, 시그너처(signature)라고 부른다.

기능 식별 이름
파라미터 및 파라미터 타입
기능 실행 결과 값

객체가 제공하는 모든 오퍼레이션 집합을 객체의 인터페이스라고 부른다.
서로 다른 인터페이스를 구분할 때 사용되는 명칭이 바로 타입이다.
인터페이스는 객체를 사용하기 위한 일종의 명세나 규칙이라고 생각하면된다.

인터페이스는 객체가 제공하는 기능에 대한 명세서일뿐, 실제 객체가 기능을 어떻게 구현하는지에 대한 내용은 포함하고 있지 않다. 실제 객체의 구현을 정의하는 것은 클래스이다.
클래스에는 오퍼레이션을 구현하는데, 필요한 데이터 및 오퍼레이션의 구현이 포함된다.

메시지
개체지향은 기능을 제공하는 여러 객체들이 모여서 완성된 어플리케이션을 구성하게 된다.
예를들어, 파일에서 데이터를 읽어오는 객체가 있고, 데이터를 암호화해주는 객체가 있고, 파일에 데이터를 쓰는 객체가 있다.
특정파일에 데이터를 읽어와 암호화한뒤 다른 파일에 쓰는 프로그램은 세개의 객체로 구성될수 있을 것이다.

                           1.read()                              3. write 실행요청
파일읽기 객체  <--------- 암호화 처리 객체 ---------> 파일쓰기 객체
                         ---------->
                          2. byte 배열리턴


파일 읽기 객체가 제공하는 인터페이스
  • 오퍼레이션 이름: read
  • 파라미터: 없음
  • 리턴타입: byte 배열

암호화 처리 객체는 파일 읽기 객체에게 read 오퍼레이션을 실행해달라는 요청을 전달하며, 요청을 받은 파일 읽기 객체는 해당 요청에 해당하는 기능을 실행한 뒤에 응답을 전달하게 된다.
이때,  오퍼레이션의 실행을 요청(request)하는 것을 '메시지(message)를 보낸다'고 표현한다.

자바와 같은 언어에서는 메서드를 호출하는 것이 메시지를 보내는 과정에 해당된다.
FileInputStream fis = new FileInputStream(fileName);
byte[] data = new byte[512];
int readBytes = fs.read(data);

fis 변수는 FileInputStream 타입의 객체를 참조하는데, fis.read(data) 코드는 fis가 참조하는 객체에 read() 오퍼레이션을 실행해 달라는 메시지를 전송한다고 생각하면 된다.


객체의 책임과 크기
객체는 객체가 제공하는 기능으로 정의된다.
객체마다 자신만의 책임(responsibility)이 있다는 의미를 갖는다.

파일읽기 객체(파일에서 데이터를 읽어와 제공하는 책임)
암호화처리객체(제공받은 데이터를 암호화해서 다른 파일에 보내는 책임)
파일쓰기 객체(파일에 데이터를 쓰는 책임)

한 객체가 갖는 책임을 정의하는 것이 바로 타입/인터페이스라고 생각하면된다.
객체가 갖는 책임은 어떻게 결정되는가?
이 결정을 하는 것이 바로 객체 지향 설계의 출발이다.

세개의 객체가 서로 다른 책임을 지는데, 이런 책임 할당이 처음부터 바로 결정된것은 아니다.
처음에는 프로그램을 만들기 위해 필요한 기능 목록을 정리해야한다.

파일의 byte 데이터를 제공한다.
파일에 byte 데이터를 쓴다.
byte 데이터를 암호화해서 새로운 byte 데이터를 생성한다.
전체 흐름을 제어한다.

이 기능을 어떻게 객체들에게 분배하느냐에 따라서 객체의 구성이 달라진다.

1.
흐름제어
byte 암호화  -> 파일읽기, 파일 쓰기

2.
흐름제어 -> 파일읽기, 파일 쓰기
    |
byte 암호화

3.
흐름제어              -> 파일읽기
     |                   -> 파일 쓰기
     |                  
byte 암호화

객체지향으로 프로그래밍을 할때, 가장 어려우면서 가장 중요한 것이 바로 객체마다 기능을 할당하는 과정이다.
다양한 조합중에서 알맞는 구성을 찾아내는 것은 쉽지 않다.

객체가 얼마나 많은 기능을 제공할 것인가에 대한 확실한 규칙이 존재하는데, 객체가 갖는 책임의 크기는 작을 수록 좋다. 객체가 제공하는 기능의 개수가 적다는걸 의미한다.

한 객체에 많은 기능이 포함되면, 그 기능과 관련된 데이터들도 한 객체에 모두 포함된다.
객체에 정의된 많은 오퍼레이션들이 데이터들을 공유하는 방식이다. 데이터를 중심으로 개발되는 절차지향방식과 동일한 구조가 된다.
객체의 책임이 커질수록 절차 지향적으로 구조가 되며, 기능 변경의 어려움(경직성 문제)발생하게 된다.
객체가 갖는 책임의 크기는 작아질수록 객체지향의 장점인 변경의 유연함을 얻을 수 있다.
단일책임원칙(Single Responsibility Principle SRP)은 객체는 단 한개의 책임만 가져야 한다.

의존
다른 객체가 제공하는 기능을 이용해서 자신의 기능을 완성하는 객체가 나타난다.
흐름제어 객체는 byte암호화 객체와 파일읽기 객체, 파일쓰기 객체를 이용해 파일 데이터 암호화 프로그램의 실행흐름 기능을 한다.

한 객체가 다른 객체를 이용한다는 것은, 실제 구현에서는 한 객체의 코드에서 다른 객체를 생성하거나 다른 객체의 메서드를 호출한다는 것을 뜻한다. 

package com.sample.event;

import java.io.FileReader;

import org.springframework.core.codec.ByteBufferEncoder;

public class FlowController {

    public void process(){
        FileDataReader reader = new FileDataReader(fileName);    //객체생성
        byte[] plainBytes = reader.read();    // 메서드 호출
        
        ByteEncryptor encryptor = new ByteEncryptor();    //객체생성
        byte[] encryptedBytes = encryptor.encrypt(plainBytes);    //메서드호출
        
        FileDataWriter writer = new FileDataWriter();    // 객체생성
        writer.write(encryptedBytes);        //메서드호출
    }
}


한 객체가 다른 객체를 생성하거나 다른 객체의 메서드를 호출할때, 그 객체에 의존(dependency)한다고 표현한다.

FilowController가 FileDataReader에 의존한다고 표현할수 있다.
객체를 생성하거나 메서드를 호출하는 것뿐만 아니라 파라미터로 전달받는 경우에도 의존한다고 볼 수 있다.

public void process(ByteEncryptor encryptor) {
//내부에서 encryptor를 사용할 가능성이 높다.
}

객체를 생성하거나 메서드를 호출하거나 파라미터로 전달받거나 다른 타입에 의존을 한다는 것은 의존하는 타입에 변경이 발생할때 함께 변경될 가능성이 높다.

FileDateWriter의 생성자가 String 타입으로 파일 경로를 받도록 수정하면, FlowCotroller 클래스의 코드에 FileDataWriter를 생성하는 코드도 수정해야 한다.

FileDataWriter writer = new FileDataWriter(outFileName);    //변경

의존의 영향은 꼬리에 꼬리를 문 것처럼 전파되는 특징이 있다.
C클래스가 B클래스에 의존하고 B클래스는 A클래스에 의존한다면, A클래스의 변경은 B클래스에 영향을 주고 다시 C클래스에 영향을 주게 된다.
C클래스의 영향은 다시 A클래스에 영향을 줄수 있다.

순환의존이 발생하지 않도록 하는 원칙으로 의존역전원칙(Dependency inversion principle DIP)

package com.sample.event;
public class Authenticator {

    public boolean authenticate(String id, String password) {
        Member m = findMemberById(id);
        if(m == null) return false;
        
        return m.equalsPassword(password);
    }
}

package com.sample.event;
public class AuthenticationHandler {

    public void handleRequest(String inputId, String inputPassword) {
        Authenticator auth = new Authenticator();
        if(auth.authenticate(inputId, inputPassword)) {
            // 아이디/암호 일치 처리
        } else {
            // 불일치 처리
        }
    }
}

AuthenticationHandler 클래스는 Authenticator 클래스를 사용하고 있다.
AuthenticationHandler 클래스가 Authenticator 클래스에 의존하고 있고, Authenticator 클래스에 변화가 생기면 AuthenticationHandler 클래스도 영향을 받게 된다.

잘못된 아이디를 입력한 것인지 아니면 암호가 틀린것인지 여부를 확인해서 시스템상에 로그를 남길경우
Authenticator의  authenticate() 메서드는 단순히 boolean 값을 리턴하면 안된다.
아이디가 잘못된건지, 암호가 잘못된건지 여부를 알려줘야한다.
AuthenticationHandler 클래스가 익셉션을 통해 인증 실패 이유를 구분할 수 있어야 한다.

public class AuthenticationHandler {

    public void handleRequest(String inputId, String inputPassword) {
        Authenticator auth = new Authenticator();
        try {
            auth.authenticate(inputId, inputPassword);
            //아이디 암호가 일치 처리
        } catch (MemberNotFoundException e) {
            //아이디가 잘못된 경우 처리
        } catch (InvalidPasswordException e) {
            //암호가 잘못된 경우 처리
        }
    }
}

public class Authenticator {

    public void authenticate(String id, String password) {
        Member m = findMemberById(id);
        if(m == null) throw new MemberNotFoundException();
        
        if(! m.equalsPassword(password)) throw new InvalidPasswordException();        
    }
}

Authenticator 클래스의 authenticate() 메서드도 변경되어야 한다.

내가 변경되면 나에게 의존하고 있는 코드에 영향을준다.
나의 요구가 변경되면 내가 의존하는 있는 타입에 영향을 준다.

캡슐화
객체지향의 장점은 한곳의 구현 변경이 다른 곳에 변경을 가하지 않도록 해준다는데 있다.
수정을 원활하게 할 수 있도록 하는 것이 객체 지향적으로 프로그래밍을 하는 이유이다.
객체지향은 기본적으로 캡슐화를 통해서 한 곳의 변화가 다른 곳에 미치는 영향을 최소화하는 것이다.

캡슐화(encapsulation)는 객체가 내부적으로 기능을 어떻게 구현하는지를 감추는 것이다.
이를 통해 내부의 기능 구현이 변경되더라도 그 기능을 사용하는 코드는 영향을 받지 않도록 만들어준다.
내부 구현 변경의 유연함을 주는 기법이 캡슐화이다.

package com.sample.event;

import java.util.Date;

public class Member {

    private Date expiryDate;
    private boolean male;
    
    public Date getExpiryDate() {
        return expiryDate;
    }
    public void setExpiryDate(Date expiryDate) {
        this.expiryDate = expiryDate;
    }
    public boolean isMale() {
        return male;
    }
    public void setMale(boolean male) {
        this.male = male;
    }
    
    public static void main(String[] args) {
        Member m = new Member();
        
        if(m.getExpiryDate() != null && m.getExpiryDate().getDate() < System.currentTimeMillis()) {
            //만료되었을 때 처리
        }
    }
    
}

Member 객체를 이용해서 만료여부를 확인하는 코드는 Member가 제공하는 expiryDate 데이터의 값과 현재 시간을 비교하게 된다.

그런데 여성회원인 경우 만료기간이 지났어도 30일간은 서비스를 사용하도록 정책이 변경된다면 만료여부를 확인하는 코드를 변경해야 한다.

        Member m = new Member();
        
        long day30 = 1000 * 60 * 60 * 24 * 30; //30일
        
        if(
            (m.isMale() && m.getExpiryDate() != null && m.getExpiryDate().getDate() < System.currentTimeMillis())
            ||
            (!m.isMale() && m.getExpiryDate() != null && m.getExpiryDate().getDate() < System.currentTimeMillis() - day30)
            ) {
            //만료되었을 때 처리
        }

만료여부를 확인하는 코드는 이미 여러 곳에 사용중이기 때문에 그 코드를 모두 찾아서 위와 같이 변경해 주어야 한다.
이후 정책에 변화가 생기면 모든 코드에 수정해야한다.

이런 원인은 데이터를 중심으로 프로그래밍을 했기 때문이다.
절차지향적으로 만들었기 때문이다.
데이터를 직접으로 사용하는 코드는 데이터의 변화에 직접정인 영향을 받기 때문에, 요구 사항의 변화로 인해 데이터의 구조나 쓰임새가 변경되면 이로 인해 데이터를 사용하는 코드들도 연쇄적으로 수정해줘야 한다.

캡슐화 된 기능구현
package com.sample.event;

import java.util.Date;

public class Member {

    private Date expiryDate;
    private boolean male;
    
    public Date getExpiryDate() {
        return expiryDate;
    }
    public void setExpiryDate(Date expiryDate) {
        this.expiryDate = expiryDate;
    }
    public boolean isMale() {
        return male;
    }
    public void setMale(boolean male) {
        this.male = male;
    }
    
    //만료 여부 확인 구현을 캡슐화
    public boolean isExpired() {
        return expiryDate != null && expiryDate.getDate() < System.currentTimeMillis();
    }
    
    public static void main(String[] args) {
        Member m = new Member();
        
        if(m.isExpired()){
            
        }
    }
}

Member 클래스의  isExpired() 메서드를 제공하는데, 다른 클래스에서는 Member 클래스가 isExpired() 메서드를 어떻게 구현했는지 알지 못한다.
회원의 서비스 사용이 만료되었으면 isExpired() 메서드가 true를 리턴하는것만 알고 있다.
만료여부 규칙이 변경해야될 경우 Member 클래스의 isExpired() 메서드만 수정하면된다.


    private static final long DAY30 = 1000 * 60 * 60 * 24 * 30; //30일
    //만료 여부 확인 구현을 캡슐화
    public boolean isExpired() {
        if(male) {
            return expiryDate != null && expiryDate.getDate() < System.currentTimeMillis();
        }
        return expiryDate != null && expiryDate.getDate() < System.currentTimeMillis() - DAY30;
    }

기능구현을 캡슐화하면 내부 구현이 변경되더라도, 기능을 사용하는 곳의 영향을 최소화할 수 있다.
캡슐화를 잘 할수록 보다 휩게 구현을 변경할 수 있따.

캡슐화를 위한 규칙
  • Tell, Don't Ask
  • 데미테르의 법칙(Law of Demeter)

Tell Don't Ask 규칙은 데이터를 물어보지 않고, 기능을 실행해 달라고 말하는 규칙이다.
절자지향방식은 만료일자 데이터를 가져와서, 직접 만료 여부를 확인한다.

데이터를 읽는 것은 데이터를 중심으로 코드를 작성하게 만드는 원인이 된다.
데이터 대신에 기능을 실행해달라고 명령을 내리려면 만료일자 데이터를 가진 객체에게 만료 여부를 확인해달라고 해야한다.
기능 실행을 요청하는 방식으로 코드를 작성하다보면, 자연스럽게 해당 기능을 어떻게 구현했는지 여부가 감춰진다.
즉, 기능 구현이 캡슐화되는 것이다.

테미테르의 법칙은 Tell Don't Ask 규칙을 따르도록 만들어주는 규칙이다.
-메서드에서 생성한 객체의 메서드만 호출
-파라미터로 받은 객체의 메서드만 호출
-필드로 참조하는 객체의 메서드만 호출

public void processSome(Member member) {
    if(member.getDate().getTime() < ... ) //테미테르 법칙 위반

파라미터로 전달 받은 객체의 메서드만 호출하도록 되어있는데, 파라미터로 전달받은 member의 getDate() 메서드를 호출한 뒤에 다시 getDate()가 리턴한 Date 객체의 getTime() 메서드를 호출했기 때문이다.

member 객체에 대해 한번의 메서드 호출로 변경해야한다.
데이터 중심이 아닌 기능 중심으로 코드를 작성하도록 유도하기 때문에 기능 구현의 캡슐화를 향상시켜 준다.

정리
객체지향 설계작업

1.제공해야 할 기능을 찾고 또는 세분화하고, 그 기능을 알맞은 객체에 할당한다.
    A.기능을 구현하는데 필요한 데이터를 객체에 추가한다. 객체에 데이터를 먼저 추가하고 그 데이터를 이용하는 기능을 넣을 수도 잇다.
    B.기능은 최대한 캡슐화해서 구현한다.
2.객체 간에 어떻게 메시지를 주고받을 지 결정한다.
3.과정1과 과정2를 개발하는 동안 지속적으로 반복한다.

파일 데이터 암호화
-파일에서 데이터읽기
-데이터를 암호화하기
-파일에 데이터쓰기

기능을 찾으면 이들 기능을 제공할 객체 후보를 찾고, 각 객체가 어떻게 연결되는지 그려본다.
이 과정에서 객체가 기능을 제공할 때 사용할 인터페이스가 도출된다.


이름은 read() 리턴타입은 byte[] => 인터페이스 도출

interface FileDataReader {
    public byte[] read();
}

객체의 크기는 한번에 완성되기 보다는 구현을 진행하는 과정에서 점진적으로 명확해진다.

암호화객체는 
-흐름제어(데이터읽고, 암호화하고, 데이터쓰고)
-데이터암호화

처음에는 이것이 불명확한 경우가 많다.
구현을 진행하는 과정에서 암호화 알고리즘을 변경해야 할때, 데이터 암호화 기능과 흐름제어가 한 객체에 섞여 있다는 것을 알게 될수도 있다. 또는 암호화 기능만 테스트하고 싶은데, 흐름제어 기능과 암호화 기능이 섞여있어서 암호화 기능만 테스트하는 것이 힘들 때 알게 될수도 있다.

한 클래스에 여러 책임이 섞여 있다는걸 알게되면, 객체를 새로 만들어서 책임을 분리하게 된다.

출처-객체지향과 디자인패턴 저자-최범균

반응형

'JAVA > OOP' 카테고리의 다른 글

의존역전원칙  (0) 2018.11.06
단일책임원칙  (0) 2018.11.06
절차지향  (0) 2018.10.16
컴포넌트  (0) 2018.04.21
다형성  (0) 2018.04.20