hongcheol / CS-study

cs지식을 정리하는 공간
MIT License
248 stars 30 forks source link

프록시 패턴 #120

Open khyunjiee opened 3 years ago

khyunjiee commented 3 years ago

Proxy Pattern

Proxy는 대리자라는 뜻이다.

즉, 프록시에게 어떤 일을 대신 시키는 것이다.
실제 기능을 수행하는 객체 Real Object 대신 가상의 객체 Proxy Object 를 사용해 로직의 흐름을 제어하는 디자인 패턴이다.

중요한 것은 흐름제어만 할 뿐 결과값을 조작하거나 변경하지 않는다.

프록시 패턴의 특징

프록시 패턴 구조

image

가상 프록시 (Virtual Proxy)

가상 프록시는 실제 객체의 사용 시점을 제어할 수 있다.

예시로 늦은 초기화(lazy initialization)를 프록시를 사용해 구현해보자.

콘솔로 20개씩 난독화된 전자 서류의 본문을 복화해서 보여주는 프로그램을 작성한다고 가정

// 텍스트 파일을 읽는 인터페이스
interface TextFile {
    String fetch();
}
class SecretTextFile implements TextFile {
    private String plainText;

    public SecretTextFile(String fileName) {
        // 특별한 복호화 기법을 통해 데이터를 복원해 내용 반환
        this.plainText = SecretFileHolder.decodeByFileName(fileName);
    }

    @Override
    public String fetch() {
        return plainText;
    }
}

SecretTextFile 클래스는 난독화 되어 있는 텍스트 파일을 복호화해서 평문으로 바꿔주는 클래스이다.

이 클래스를 협업 조직에서 라이브러리로 제공해줬다고 가정한다면, 개발자는 이 클래스를 수정할 권한은 없다.
이 클래스를 그대로 사용해 콘솔 프로그램을 구성한 후 실행해봤더니 첫 결과가 나오기까지 6초의 시간이 걸린다는 것을 알게 되었다.

6초는 제대로 프로그램이 동작하고 있는지 의심할 수 있는 충분한 시간이다.
이유를 확인해보니 SecretTextFile 클래스에서 사용중인 SecretFileHolder.decodeByFileName() 메소드의 수행속도가 0.3초라는 것을 발견하게 되었다.

그리고 목록에 20개의 파일 내용을 노출해야 하는 상태였기 때문에 문제가 된 것이다.

화면을 구성할 때 이 파일들을 전부 객체로 만들다 보니 6초의 로딩 시간을 갖게 된 것이다.
문제를 확인하고 코드 리팩토링을 하려고 한다.

프록시 패턴을 적용해 필요할 때만 파일 복호화를 하도록 수정하기로 했다.

class ProxyTextFile implements TextFile {
    private String fileName;
    private TextFile textFile;

    public ProxyTextFile(String fileName) {
        this.fileName = fileName;
    }

    @Override
    public String fetch() {
        if (textFile == null) {
            textFile = new SecretTextFile(fileName);
        }

        return "[proxy] " + textFile.fetch();
    }
}

ProxyTextFile 클래스는 객체를 생성할 때에는 별다른 동작을 하지 않는다.

하지만 실제로 데이터를 가져와야할 때 SecretTextFile 객체를 만들어내고 기능을 위임한다.

void main() {
    List<TextFile> textFileList = new ArrayList<>();

    textFileList.addAll(TextFileProvider.getSecretTextFile(0, 3));
    textFileList.addAll(TextFileProvider.getProxyTextFile(3, 20));

    textFileList.stream().map(TextFile::fetch).forEach(System.out::println);
}

위 코드는 처음 3개의 파일만 실제 객체를 사용하고 나머지는 프록시 객체를 사용해 프로그램에서 첫 결과가 나오는 것을 1초 내로 만든다.

textFileList 를 사용하는 입장에서는 다른 조치 없이 그대로 사용하면 된다.

콘솔에서 textFileList 를 순회하면서 출력한다고 하면 처음 세개는 이미 로딩이 되어 있는 상태이므로 바로 출력하고 그 다음 아이템부터는 복호화가 될 때마다 출력할 것이다.
ProxyTextFile 같은 프록시 클래스를 만들고 기존 SecretTextFile 클래스 대신 사용한 것만으로도 객체 생성 시간이 대폭 감소한다. 필요한 시점에 텍스트를 복호화하게 된 것이다.

이렇게 초기 비용이 많이 드는 연산이 포함된 객체는 가상 프록시를 사용했을 때 효과를 볼 수 있다.

보호 프록시 (Protection Proxy)

보호 프록시는 프록시 객체가 사용자의 실제 객체에 대한 접근을 제어한다.

인사팀에서 인사정보에 대한 데이터 접근을 직책 단위로 세분화 하려고 한다. 전산팀에 근무중인 나는 직책에 따라서 조직원의 인사정보 접근을 제어하는 업무를 수행해야 한다고 가정.

// 직책 등급(차례대로 조직원, 조직장, 부사장)
enum GRADE {
    Staff, Manager, VicePresident
}

// 구성원
interface Employee {
    String getName(); // 구성원의 이름
    GRADE getGrade(); // 구성원의 직책
    String getInformation(Employee viewer); // 구성원의 인사정보(매개변수는 조회자)
}

// 일반 구성원
class NormalEmployee implements Employee {
    private String name;
    private GRADE grade;

    public NormalEmployee(String name, GRADE grade) {
        this.name = name;
        this.grade = grade;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public GRADE getGrade() {
        return grade;
    }

    // 기본적으로 자신의 인사정보는 누구나 열람할 수 있도록 되어있습니다.
    @Override
    public String getInformation(Employee viewer) {
        return "Display " + getGrade().name() + " '" + getName() + "' personnel information.";
    }
}

위 코드는 기존에 있던 코드이다. 이미 위와 같은 구조로 라이브러리화 되어 프로젝트의 많은 곳에서 사용 중이다.

가장 좋은 것은 라이브러리를 제공한 조직에서 변경해주는 것이지만 기다릴 시간은 없다.

하지만 위와 같은 상태로 놔둔다면 누구든지 Employee 객체에서 getInformation() 메소드를 호출하면 누구든지 정보를 보여줄 것이다.

이것을 보호 프록시를 사용해 리팩토링해보자.

// 인사정보가 보호된 구성원(인사 정보 열람 권한 없으면 예외 발생)
class ProtectedEmployee implements Employee {
    private Employee employee;

    public ProtectedEmployee(Employee employee) {
        this.employee = employee;
    }

    @Override
    public String getInformation(Employee viewer) {
        // 본인 인사정보 조회
        if (this.employee.getGrade() == viewer.getGrade() && this.employee.getName().equals(viewer.getName())) {
            return this.employee.getInformation(viewer);
        }

        switch (viewer.getGrade()) {
            case VicePresident:
                // 부사장은 조직장, 조직원들을 볼 수 있다.
                if (this.employee.getGrade() == GRADE.Manager || this.employee.getGrade() == GRADE.Staff) {
                    return this.employee.getInformation(viewer);
                }
            case Manager:
                if (this.employee.getGrade() == GRADE.Staff) { // 조직장은 조직원들을 볼 수 있다.
                    return this.employee.getInformation(viewer);
                }
            case Staff:
            default:
                throw new NotAuthorizedException(); // 조직원들은 다른 사람의 인사정보를 볼 수 없다.
        }
    }

    @Override
    public String getName() {
        return employee.getName();
    }

    @Override
    public GRADE getGrade() {
        return employee.getGrade();
    }
}

class NotAuthorizedException extends RuntimeException {
    private static final long serialVersionUID = -1714144282967712658L;
}

보호 프록시에서 메소드 호출 시 조회자에게 권한이 없으면 NotAuthorizedException 예외를 던진다.

public void main() {
    // 직원별 개인 객체 생성
    Employee CTO = new NormalEmployee("Dragon Jung", GRADE.VicePresident);
    Employee CFO = new NormalEmployee("Money Lee", GRADE.VicePresident);
    Employee devManager = new NormalEmployee("Cats Chang", GRADE.Manager);
    Employee financeManager = new NormalEmployee("Dell Choi", GRADE.Manager);
    Employee devStaff = new NormalEmployee("Dark Kim", GRADE.Staff);
    Employee financeStaff = new NormalEmployee("Pal Yoo", GRADE.Staff);

    // 직원들을 리스트로 가공.
    List<Employee> employees = Arrays.asList(CTO, CFO, devManager, financeManager, devStaff, financeStaff);

    System.out.println("================================================================");
    System.out.println("시나리오1. Staff(Dark Kim)가 회사 인원 인사 정보 조회");
    System.out.println("================================================================");

    // 자신의 직급에 관계 없이 모든 직급의 인사 정보를 열람 (문제!!)
    printAllInformationInCompany(devStaff, employees);

    System.out.println("================================================================");
    System.out.println("보호 프록시 서비스를 가동.");
    System.out.println("================================================================");
    List<Employee> protectedEmployees = employees.stream().map(ProtectedEmployee::new).collect(Collectors.toList());

    System.out.println("================================================================");
    System.out.println("시나리오2. Staff(Dark Kim)가 회사 인원 인사 정보 조회");
    System.out.println("================================================================");
    printAllInformationInCompany(devStaff, protectedEmployees);

    System.out.println("================================================================");
    System.out.println("시나리오3. Manger(Cats Chang)가 회사 인원 인사 정보 조회");
    System.out.println("================================================================");
    printAllInformationInCompany(devManager, protectedEmployees);

    System.out.println("================================================================");
    System.out.println("시나리오4. VicePresident(Dragon Jung)가 회사 인원 인사 정보 조회");
    System.out.println("================================================================");
    printAllInformationInCompany(CTO, protectedEmployees);
}

public void printAllInformationInCompany(Employee viewer, List<Employee> employees) {
    employees.stream()
            .map(employee -> {
                try {
                    return employee.getInformation(viewer);
                } catch (NotAuthorizedException e) {
                    return "Not authorized.";
                }
            })
            .forEach(System.out::println);
}

image

이후 남은 일은 프로젝트 곳곳의 NormalEmployeeProtectedEmployee 로 수정하면 반영이 완료된다.

REFERENCE

https://jdm.kr/blog/235