ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 엔터프라이즈 애플리케이션 아키텍처 패턴 3 - 데이터 전송 객체, 기본 패턴
    책책책 책을 읽읍시다/프로그래밍 2023. 3. 14. 23:43

    15장. 분산 패턴 - 데이터 전송 객체


    메서드 호출 횟수를 줄이기 위해 프로세스 간에 데이터를 전송하는 객체이다.

    DTO 패턴 사용 예시 다이어그램

     원격 파사드와 같은 원격 인터페이스를 사용할 때는 각 호출의 비용이 상당히 부담스럽다. 따라서 호출 횟수를 줄여야 하며, 이를 위해서는 각 호출에서 더 많은 데이터를 전송해야 한다. 한 가지 방법은 다수의 매개변수를 사용하는 것이다. 그러나 이 방식은 프로그램을 작성하기에는 상당히 불편하며, 자바와 같이 단일 값만 반환할 수 있는 언어에서는 아예 불가능하다.

     이 문제를 해결하는 방법은 호출에 필요한 모든 데이터를 저장하는 데이터 전송 객체(Data Transfer Object)를 만들어 사용하는 것이다. 데이터 전송 객체는 직렬화가 가능해야 연결을 통해 전송할 수 있다. 일반적으로 데이터 전송 객체와 도메인 객체 간에 데이터를 전송하기 위해 서버 측에서 어셈블러가 사용된다.

     

     데이터 전송 객체는 사실 여러 면에서 그리 바람직하게 보이지 않는 객체이며, 일반적으로 다수의 필드와 이러한 필드를 위한 접근자 메서드와 설정자 메서드를 포함하는 단순한 구조를 가진다. 데이터 전송 객체는 네트워크 상에서 한 번의 호출로 많은 정보를 전송하기 위해 설계됐으며, 분산 시스템을 구현하는 데 핵심적인 개념이다.

     원격 객체는 데이터가 필요할 때마다 적절한 데이터 전송 객체를 요청한다. 일반적으로 데이터 전송 객체는 원격 객체가 요청한 것보다 훨씬 많은 데이터를 가져오며, 실제로는 원격 객체가 한동안 사용할 모든 데이터를 가져와야 한다. 원격 호출의 지연 비용을 감안할 때 여러 번 추가로 호출하는 것보다는 필요 이상의 데이터를 전송하는 것이 낫다.

     데이터 전송 객체 하나는 일반적으로 서버 객체를 두 개 이상 포함하며, 원격 객체가 데이터를 원할 가능성이 있는 모든 서버 객체에서 데이터를 가져와 집계한다. 예를 들어, 원격 객체가 한 주문 객체에 대한 데이터를 요청한 경우 반환된 데이터 전송 객체에는 해당 주문, 고객, 주문 품목, 주문 품목의 상품, 배송 정보 등의 관련 정보가 모두 포함될 수 있다.

     일반적으로 도메인 모델에서 객체를 전송할 수는 없다. 그 이유는 객체는 일반적으로 직렬화가 적어도 매우 어렵거나 아예 불가능한 복잡한 연결망에 연결돼 있기 때문이다. 또한 전체 도메인 모델을 복사하는 것과 마찬가지이므로 일반적으로 클라이언트에서는 도메인 객체 클래스를 원하지도 않는다. 그 대신 도메인 객체에서 단순화된 형식의 데이터를 전송해야 한다.

     데이터 전송 객체의 필드는 상당히 단순하며, 기본형이거나 문자열 및 날짜와 같은 다른 클래스 또는 다른 데이터 전송 객체일 수 있다. 데이터 전송 객체 내의 모든 구조는 도메인 모델에서 볼 수 있는 복잡한 그래프 구조와는 다른 간단한 그래프 구조(일반적으로 하나의 계층)여야 한다. 모든 구조는 직렬화돼야 하고 전송하는 양쪽에서 쉽게 이해할 수 있어야 하므로 이러한 단순한 속성을 유지해야 한다. 따라서 데이터 전송 객체 클래스와 여기서 참조하는 모든 클래스는 양쪽에 존재해야 한다.

     데이터 전송 객체는 특정 클라이언트의 필요성에 맞게 설계하는 것이 이치에 맞다. 데이터 전송 객체가 웹 페이지나 GUI 화면에 대응되는 경우가 많은 것도 이 때문이다. 특정 화면에 따라 하나의 주문에 대해 여러 데이터 전송 객체가 사용되는 경우도 있다. 물론 여러 프레젠테이션에서 비슷한 데이터를 필요로 하는 경우 하나의 데이터 전송 객체로 모두 처리하는 것이 이치에 맞다.

     이와 관련된 다른 고려 사항으로 데이터 전송 객체 하나를 전체 상호작용에 사용할지, 아니면 요청마다 각기 다른 데이터 전송 객체를 사용할지에 대한 것이 있다. 다른 데이터 전송 객체를 사용하면 각 호출에 어떤 데이터가 전송되는지 확인하기 쉽지만 데이터 전송 객체가 많아지는 문제가 있다. 데이터 전송 객체 하나를 사용하면 해야 하는 작업은 줄지만 각 호출에서 정보가 어떻게 전송되는지 알아보기 힘들다. 필자는 데이터 간에 공통점이 많은 경우 데이터 전송 객체 하나를 사용하는 편이지만, 특정한 요청을 처리하는 데 적합하다고 판단하면 주저하지 않고 다른 데이터 전송 객체를 사용한다. 이것은 일괄적인 규칙을 정할 수 없는 사항 중 하나이므로 예를 들어 대부분의 상호작용에 특정한 데이터 전송 객체 하나를 사용하고 한두 개의 요청과 응답에는 다른 데이터 전송 객체를 사용할 수 있다.

     비슷한 고려 사항으로 요청과 응답에 데이터 전송 객체 하나를 사용할지 아니면 각기 다른 데이터 전송 객체를 사용할지에 대한 것이 있다. 이 경우에도 역시 일괄적인 규칙이 적용되지 않는다. 요청과 응답의 데이터가 상다히 비슷하다면 한 객체를 사용하고 다르다면 두 객체를 사용한다.

     읽기 전용 데이터 전송 객체를 선호하는 사람이 있다. 이 체계에서는 클라이언트로부터 데이터 전송 객체 하나를 받고 동일한 클래스라고 하더라도 다른 객체를 생성하고 전송한다. 반대로 변경 가능한 요청 데이터 전송 객체를 선호하는 사람도 있다. 필자는 두 방식에 대해 특별한 의견은 없지만 대체적으로는 응답에 대한 객체를 새로 생성하더라도 데이터를 점진적으로 넣을 수 있는 변경 가능한 데이터 전송 객체를 선호하는 편이다. 읽기 전용 데이터 전송 객체를 선호하는 측에서 주장하는 사항에는 값 객체와의 이름 혼란과 관련된 것이 있다.

     일반적으로 데이터 전송 객체의 형식으로 SQL 쿼리에서 얻는 것과 동일한 테이블 형식의 데이터인 레코드 집합이 있다. 실제로 레코드 집합은 SQL 데이터베이스를 위한 데이터 전송 객체다. 여러 아키텍처에서 설계 전체에 이를 사용하고 있다. 도메인 모델은 클라이언트로 전송할 데이터의 레코드 집합을 생성할 수 있으며, 클라이언트는 이를 SQL에서 직접 받은 것처럼 취급한다. 이 방식은 클라이언트가 레코드 집합 구조와 밀접한 툴을 가진 경우 유용하다. 레코드 집합은 완전하게 도메인 논리를 통해 생성될 수도 있지만 이보다는 SQL 쿼리를 통해 생성되고 도메인 논리를 통해 수정된 후 프레젠테이션으로 전달되는 것이 일반적이다. 이 스타일은 테이블 모듈에 도움이 된다.

     다른 형식의 데이터 전송 객체로 범용 컬렉션 자료구조가 있다. 여기에 배열을 사용하는 경우를 직접 본 경험이 있는데, 배열은 코드를 알아보기 힘들게 만들기 때문에 필자는 권장하지 않는다. 최상의 컬렉션은 의미가 있는 문자열을 키로 사용하는 딕셔너리(dictionary)다. 여기서 문제는 명시적 인터페이스와 엄격한 형식 지정의 장점을 잃어버린다는 것이다. 딕셔너리는 직접 명ㅅ히적 객체를 작성하는 것보다 다루기 수월하므로 적절한 생성자가 없을 때 임시 용도로 사용하는 데 적합하다. 그러나 생성기가 있을 때는 명시적 인터페이스를 사용하는 것이 좋다. 특히 다른 컴포넌트 간에 통신 프로토콜로 사용하는 것을 고려할 때는 더욱 그렇다.

     

     데이터 전송 객체는 간단한 접근자 메서드와 설정자 메서드를 제공하는 것 외에도 자신을 전송 가능한 형식으로 직렬화하는 책임을 가지고 있다. 어떤 형식을 사용할지는 연결 양쪽에 무엇이 있는지, 연결을 통해 무엇을 전송할 수 있는지, 그리고 직렬화의 난이도가 어느 정도인지에 따라 달라진다. 많은 플랫폼에서 간단한 객체에 대한 직렬화를 기본 제공한다. 예를 들어, 자바는 이진 직렬화를 기본 제공하며 .NET은 이진 및 XML 직렬화를 기본 제공한다. 데이터 전송 객체는 도메인 모델의 객체를 다룰 때 경험하는 복잡성이 없는 간단한 구조이므로 기본 제공 직렬화가 있는 경우 일반적으로 이를 거의 곧바로 사용할 수 있다. 필자도 가능하면 거의 항상 자동 메커니즘을 사용한다.

     연결의 양쪽에서 작동할 수 있는 메커니즘을 선택해야 한다. 양쪽을 모두 제어할 수 있다면 가장 쉬운 메커니즘을 선택한다. 제어할 수 없는 쪽(예: 외래 컴포넌트)이 있으면 이에 해당하는 커넥터를 제공하는 방법이 있다. 커넥터를 외래 컴포넌트에 적용하면 연결 양쪽에 간단한 데이터 전송 객체를 사용할 수 있다.

     데이터 전송 객체를 사용하려면 먼저 텍스트 또는 이진 직렬화 형식 중 하나를 선택해야 한다. 텍스트 직렬화는 읽고 통신 내용을 확인하기 쉽다. 텍스트 직렬화 방식 중에는 해당 형식으로 문서를 생성하고 구문 분석하는 툴이 많이 보급된 XML이 인기가 많다. 텍스트 직렬화의 가장 큰 단점은 동일한 데이터를 전송하는 데 더 많은 대역폭이 필요하며(특히 XML의 경우 대역폭을 많이 소비함) 성능이 많이 저하될 수 있다는 것이다.

     직렬화에 대한 중요한 고려 사항 중 하나는 연결 양쪽에서 데이터 전송 객체의 동기화다. 이론상으로는 서버가 데이터 전송 객체의 정의를 변경할 때마다 클라이언트도 업데이트하지만 실제로 그렇지 않을 수 있다. 구형 컨트롤러로 서버에 접근하면 반드시 문제가 발생하며, 직렬화는 이 문제를 어느 정도 더 악화시킬 수 있다. 이 경우 데이터 전송 객체를 순수하게 이진 직렬화하는 체계에서는 통신 내용이 완전하게 손실될 수 있다. 구조를 조금만 변경해도 역직렬화에 오류가 발생하기 때문이다. 옵션 필드를 추가하는 등의 무해해 보이는 변경도 이러한 결과를 낳는다. 결과적으로 직접 이진 직렬화는 통신 라인의 취약성을 높일 수 있다.

     

     데이터 전송 객체는 연결 양쪽에 배포되므로 도메인 객체와 연결하는 방법을 알 필요가 없다. 따라서 데이터 전송 객체가 도메인 객체에 의존하는 것은 바람직하지 않다. 또한 인터페이스 형식을 변경하면 데이터 전송 객체의 구조도 변경되므로 도메인 객체가 데이터 전송 객체에 의존하는 것도 좋지 않다. 일반적으로 도메인 모델은 외부 인터페이스에 대해 독립적으로 유지하는 것이 좋다.

     따라서 도메인 모델로부터 데이터 전송 객체를 생성하고 데이터 전송 객체로부터 모델을 업데이트하는 별도의 어셈블러 객체를 만드는 것이 좋다. 어셈블러는 데이터 전송 객체와 도메인 객체를 매핑한다는 점에서 일종의 매퍼에 해당한다.

    어셈블러 객체를 사용해 도메인 모델과 데이터 전송 객체를 매핑

     

    예제: 앨범에 대한 정보 전송

    이 예제에서는 아래 도메인 모델을 사용한다. 

    음악가와 앨범의 클래스 다이어그램

     데이터 전송 객체를 사용하면 이 구조를 더 간소화할 수 있다. 음악가 클래스의 연관 데이터는 앨범 DTO로 축소했고 트랙의 연주자는 문자열의 배열로 나타냈다. 이것이 데이터 전송 객체에서 자료구조를 간소화하는 데 자주 사용되는 방법이다. 데이터 전송 객체는 앨범에 대해 하나, 그리고 각 트랙에 대해 하나씩 두 가지가 있다. 이 예에서는 다른 두 객체 중 하나에 필요한 데이터가 모두 있으므로 음악가에 대한 객체는 필요 없다. 앨범에 여러 트랙이 있고 각 항목에 둘 이상의 데이터 항목에 포함될 수 있으므로 트랙은 전송 객체로만 사용했다.

    데이터 전송 객체의 클래스 다이어그램

    다음은 도메인 모델에서 데이터 전송 객체를 기록하는 코드다. 어셈블러는 원격 파사드와 같은 원격 인터페이스를 처리하는 객체에 의해 호출된다.

    public class AlbumAssembler {
    
        public AlbumDTO writeDTO(Album subject) {
            AlbumDTO result = new AlbumDTO();
            result.setTitle(subject.getTitle());
            result.setArtist(subject.getArtist().getName());
            writeTracks(result, subject);
            return result;
        }
    
        private void writeTracks(AlbumDTO result, Album subject) {
            List newTracks = new ArrayList();
            Iterator it = subject.getTracks().iterator();
            while (it.hasNext()) {
                TrackDTO newDTO = new TrackDTO();
                Track thisTrack = (Track) it.next();
                newDTO.setTitle(thisTrack.getTitle());
                writePerformers(newDTO, thisTrack);
                newTracks.add(newDTO);
            }
            result.setTracks((TrackDTO[]) newTracks.toArray(new TrackDTO[0]));
        }
    
        private void writePerformers(TrackDTO dto, Track subject) {
            List result = new ArrayList();
            Iterator it = subject.getPerformers().iterator();
            while (it.hasNext()) {
                Artist each = (Artist) it.next();
                result.add(each.getName());
            }
            dto.setPerformers((String[]) result.toArray(new String[0]));
        }
    
        public void createAlbum(String id, AlbumDTO source) {
            Artist artist = Registry.findArtistNamed(source.getArtist());
            if (artist == null)
                throw new RuntimeException("No artist named " + source.getArtist());
            Album album = new Album(source.getTitle() artist);
            createTracks(source.getTracks(), album);
            Registry.addAlbum(id, album);
        }
    
        private void createTracks(TrackDTO[] tracks, Album album) {
            for (int i = 0; i < tracks.length; i++) {;
                Track newTrack = new Track(tracks[i].getTitle());
                album.addTrack(newTrack);
                createPerformers(newTrack, tracks[i].getPerformers());
            }
        }
    
        private void createPerformers(Track newTrack, String[] performerArray) {
            for (int i = 0; i< performerArray.length; i++) {
                Artist performer = Registry.findArtistNamed(performerArray[i]);
                if (performer == null)
                    throw new RuntimeException("No artist named " + performerArray[i]);
                newTrack.addPerformer(performer);
            }
        }
        
        public void updateAlbum(String id, AlbumDTO source) {
            Album current = Registry.findAlbum(id);
            if (current == null)
                throw new RuntimeException("Album does not exist: " + source.getTitle());
            if (source.getTitle() != current.getTitle()) current.setTitle(source.getTitle());
            if (source.getArtist() != current.getArtist().getName()) {
                Artist artist = Registry.findArtistNamed(source.getArtist());
                if (artist == null)
                    throw new RuntimeException("No artist named " + source.getArtist());
                current.setArtist(artist);
            }
            updateTracks(source, current);
        }
        
        private void updateTracks(AlbumDTO source, Album current) {
            for (int i = 0; i < source.getTracks().length; i++) {
                current.getTrack(i).setTitle(source.getTrackDTO(i).getTitle());
                current.getTrack(i).clearPerformers();
                createPerformers(current.getTrack(i), source.getTrackDTO(i).getPerformers());
            }
        }
    }

    이 예제에서는 원시 이진 직렬화를 사용한다. 즉, 양쪽의 데이터 전송 객체 클래스에 동기화가 유지되도록 주의해야 한다. 서버 데이터 전송 객체의 자료구조를 변경하고 클라이언트는 변경하지 않으면 전송 중에 오류가 발생한다. 맵을 직렬화에 사용하면 전송 중에 내결함성을 높일 수 있다.

    public class TrackDTO {
        
        public Map writeMap() {
            Map result = new HashMap();
            result.put("title", title);
            result.put("performers", performers);
            return result;
        }
        
        public static TrackDTO readMap(Map arg) {
            TrackDTO result = new TrackDTO();
            result.title = (String) arg.get("title");
            result.performers = (String[]) arg.get("performers");
            return result;
        }
    }

    이제 서버에 필드를 추가하고 이전 클라이언트를 사용하는 경우 클라이언트는 새로운 필드를 이해하지는 못하지만 나머지 데이터는 정상적으로 처리한다.

    물론 이러한 직렬화와 역직렬화 루틴을 직접 작성하는 일은 아주 지루하다. 계층 상위 형식에서 다음과 같은 리플렉션 루틴을 사용하면 이러한 지루한 작업을 크게 줄일 수 있다.

    public class DataTransferObject {
        
        public Map writeMapReflect() {
            Map result = null;
            try {
                Field[] fields = this.getClass().getFields();;
                result = new HashMap();
                for (int i = 0; i < fields.length; i++)
                    result.put(fields[i].getName(), fields[i].get(this));
            } catch (Exception e) {
                throw new ApplicationException(e);
            }
            return result;
        }
        
        public static TrackDTO readMapReflect(Map arg) {
            TrackDTO result = new TrackDTO();
            try {
                Field[] fields = result.getClass().getDeclaredFields();
                for (int i = 0; i < fields.length; i++)
                    fields[i].set(result, arg.get(fields[i].getName()));
            } catch (Exception e) {
                throw new ApplicationException (e);
            }
            return result;
        }
    }

    이러한 루틴으로 대부분의 상황을 아주 매끄럽게 처리할 수 있다(기본형을 처리하는 코드는 추가해야 한다).

     

    19장. 기본 패턴 - 분리 인터페이스, 값 객체


    분리 인터페이스

    구현과 분리된 별도의 패키지에 인터페이스를 정의한다.

    클라이언트 패키지의 인터페이스를 구현한 다이어그램

     시스템을 구성하는 부분 간의 결합을 줄이면 시스템의 설계를 개선할 수 있다. 이를 위한 좋은 방법은 클래스를 패키지로 그룹화하고 이들 간의 의존성을 제어하는 것이다. 그 다음에는 한 패키지의 클래스가 다른 패키지의 클래스를 호출하는 것에 대한 규칙을 적용할 수 있다. 예를 들어, 도메인 계층의 클래스는 프레젠테이션 패키지의 클래스를 호출하지 못하게 할 수 있다.

     그러나 일반적인 의존성 구조를 위반하고 메서드를 호출해야 하는 경우가 있다. 이 경우 분리 인터페이스(Separated Interface)를 사용해 한 패키지에 인터페이스를 정의하고 다른 곳에서 구현할 수 있다. 인터페이스에 대한 의존성이 필요한 클라이언트는 이 방식을 통해 구현을 전혀 의식하지 않고 작업을 수행할 수 있다. 분리 인터페이스는 게이트웨이를 연결할 수 있는 좋은 위치다.

     

     이 패턴은 아주 간단하게 적용할 수 있으며, 구현은 해당 인터페이스에 의존하지만 그 반대는 해당되지 않는다는 점을 활용한다. 즉, 인터페이스와 구현을 별도의 패키지에 넣고 구현 패키지가 인터페이스 패키지에 대한 의존성을 갖게 한다. 다른 패키지는 구현 패키지에 의존하지 않고 인터페이스 패키지에 의존할 수 있다.

     물론 인터페이스의 구현이 아예 없으면 런타임에 소프트웨어가 제대로 작동하지 않는다. 이 문제는 컴파일 시 둘을 연결하는 별도의 패키지를 사용하거나 구성 시 플러그인을 사용해 해결할 수 있다.

    분리 인터페이스를 다른 패키지에 배치하는 경우

     인터페이스는 클라이언트의 패키지에 넣거나 다른 패키지에 넣을 수 있다. 구현에 대한 클라이언트가 단 하나이거나 모든 클라이언트가 동일한 패키지에 있는 경우 인터페이스를 클라이언트와 함께 넣는 것이 낫다. 이 개념은 인터페이스를 정의할 책임이 클라이언트 패키지의 개발자에게 있다고 생각하면 이해하기 쉽다. 근본적으로 클라이언트 패키지는 자신이 정의하는 인터페이스를 구현하는 다른 모든 패키지와 함께 작업한다는 것을 나타낸다. 클라이언트 패키지가 여러 개인 경우 다른 인터페이스를 활용하는 것이 좋다. 인터페이스를 정의하는 역할이 클라이언트 패키지 개발자의 책임이 아니라는 점을 나타내련느 경우에도 이 방법이 좋다. 즉, 구현의 개발자가 인터페이스를 정의하는 경우가 이에 해당한다.

     인터페이스에서 사용할 언어 기능에 대해서도 고려해야 한다. 자바나 C# 같이 인터페이스 구조를 가진 언어의 경우 인터페이스 키워드를 사용하는 것이 확실한 방법이다. 그러나 이것이 최선의 방법은 아닐 수 있으며 공통적이고 선택적인 구현 동작을 포함할 수 있는 추상 클래스도 인터페이스로 사용하는 데 아주 적합하다.

     분리 인터페이스에서는 구현을 인스턴스화하기가 약간 불편할 수 있으며, 일반적으로 구현 클래스에 대한 정보가 필요하다. 일반적인 방법은 벼롣의 팩터리 객체와 팩터리에 대한 분리 인터페이스를 사용하는 것이다. 이 경우 구현을 팩터리에 바인딩해야 하며 여기에는 플러그인을 사용하는 것이 좋다. 이렇게 하면 의존성이 없는 것은 물론이고 구현 클래스에 대한 결정을 구성 시점까지 연기할 수 있다.

     굳이 플러그인을 사용하고 싶지 않다면 인터페이스와 구현을 모두 인식하는 다른 패키지를 사용해 애플리케이션 시작 시 해당 객체를 인스턴스화하는 방법이 있다. 분리 인터페이스를 사용하는 객체는 직접 인스턴스화하거나 시작 시 팩터리를 사용해 인스턴스화할 수 있다.

     

     분리 인터페이스는 한 시스템을 구성하는 두 부분을 격리하는 데 사용한다. 다음과 같은 적용 예를 생각해볼 수 있다.

    • 프레임워크 패키지에 넣은 범용 추상 코드에서 특정한 애플리케이션 코드를 호출해야 한다.
    • 한 계층의 코드에서 볼 수 없어야 하는 다른 계층의 코드를 호출해야 한다(예: 도메인 코드에서 데이터 매퍼를 호출하는 경우).
    • 다른 개발 그룹에서 개발한 함수를 호출해야 하지만 해당 API에 대한 의존성을 원하지 않는다.

     작성하는 모든 클래스에 분리 인터페이스를 사용하는 개발자들이 많이 있는데, 애플리케이션 개발 분야에서 이렇게 할 필요는 없다고 생각한다. 분리 인터페이스와 구현을 유지하려면 추가 작업을 해야 하며 팩터리 클래스(인터페이스와 구현이 포함된)까지 필요한 경우도 많다. 애플리케이션 개발 분야에서는 의존성을 제거하려는 경우 또는 여러 독립적 구현을 사용하려는 경우에만 분리 인터페이스를 사용하는 것이 바람직하다. 인터페이스와 구현을 한곳에 넣고 나중에 분리해야 한다면 이것은 필요할 때까지 연기할 수 있는 간단한 리팩터링이다.

     이러한 방식의 직접적인 의존성 관리는 지나친 수준이 되지 않게 주의해야 한다. 일반적으로 객체를 생성하기 위한 의존성만 유지하고 이후에는 인터페이스를 사용하는 정도면 충분하다. 반면 빌드 시 의존성을 검사하는 등의 방법으로 의존성 규칙을 강제하려고 하면 그때부터 문제가 복잡해진다. 이 경우 모든 의존성을 제거해야 한다. 소규모 시스템에서는 의존성 규칙을 적용하기가 그리 어렵지 않지만 대규모 시스템에서는 상당한 많은 경험이 필요하다.

     

    값 객체

    금액이나 날짜 범위와 같이 동등성의 개념이 식별자에 기반을 두지 않는 작고 간단한 객체를 값 객체(Value Object)라고 한다. 다양한 종류의 객체 시스템에서 참조 객체와 값 객체를 구분해서 생각하면 유용하다. 두 가지 객체 중 값 객체는 일반적으로 더 작고, 순수한 객체지향이 아닌 여러 언어에서 제공되는 기본형(primitive type)과 비슷하다.

     

    참조 객체와 값 객체의 차이를 명확하게 정의하기는 까다로운 일일 수 있다. 넓은 의미에서 말하면 값 객체는 금액 객체나 날짜와 같은 작은 객체이며 참조 객체는 주문이나 고객과 같이 큰 객체다. 이러한 정의는 이해하기는 쉽지만 지나치게 비형식적이다.

     참조 객체와 값 객체의 가장 중요한 차이는 두 객체가 동등성을 처리하는 방식에 있다. 참조 객체는 동등성을 판단하는 기준으로 식별자를 사용한다. 이 식별자는 객체지향 프로그래밍 언어에서 기본 제공하는 식별자나 일종의 ID 번호 또는 관계형 데이터베이스의 기본 키와 같은 개념일 수 있다. 반면 값 객체는 클래스 안의 필드 값을 기준으로 동등성을 판단한다. 

     이러한 차이는 두 객체를 처리하는 방법에서 잘 드러난다. 값 객체는 작고 쉽게 생성할 수 있으므로 참조가 아닌 값으로 전달하는 경우가 많다. 예를 들어, 한 시스템 안에 2001년 3월 18일을 나타내는 날짜 객체가 여러 개라도 아무 문제가 없다. 또한 두 객체가 물리적으로 동일한 날짜 객체인지 또는 값만 동일한지 여부도 중요하지 않다.

     대부분의 언어에서는 값 객체를 위한 기능을 따로 제공하지 않는다. 이러한 언어에서 값 객체가 올바르게 작동하게 하려면 값 객체를 읽기 전용으로 만들어 일단 생성한 뒤에는 필드를 변경할 수 없게 하는 것이 좋다. 이것은 별칭 버그(alias bug)를 방지하기 위한 것이다. 별칭 버그는 두 객체가 동일한 값 객체를 공유할 때 둘 중 한 소유자가 객체의 값을 변경하는 것을 의미한다. 예를 들어, 마틴이 입사한 날짜가 3월 18일이고 같은 날에 신디가 입사했다면 신디의 입사일을 마틴의 입사일로 설정할 수 있다. 그런데 마틴이 자신의 입사을을 5월로 수정하면 신디의 입사일까지 변경된다. 이 개념이 올바른지 여부와는 관계없이 이 결과는 사람들이 원하는 결과는 아니다. 이와 같은 작은 값의 경우 기존 날짜 객체를 새 날짜 객체로 대체하는 방법으로 입사일을 변경하는 것이 일반적인 방법이다. 값 객체를 읽기 전용으로 만들면 이러한 일반적인 개념에 맞는다.

     값 객체는 완성된 레코드로 저장해서는 안 되며, 포함 값 또는 직렬화 LOB를 대신 사용해야 한다. 값 객체는 작으므로 값 객체의 값을 사용해 SQL 쿼리를 수행할 수 있는 포함 값이 일반적으로 가장 좋은 선택이다.

     이진 직렬화 작업이 많은 경우, 특히 자바와 같이 값 객체를 특별한 방법으로 취급하지 않는 언어에서는 값 객체의 직렬화를 최적화해서 성능을 개선할 수 있다.

     

    댓글

Designed by Tistory.