setter 쓰지 말라고만 하고 가버리면 어떡해요

backfox·2022년 7월 31일
75

부트캠프 과정을 잘 견뎌내고 팀프로젝트에서 백엔드를 맡은 엄준식(27)씨.
백엔드 커리큘럼을 유난히 즐거워했던 준식씨였기에 자신이 맡은 파트가 꽤 마음에 드는 모양이다.

 
준식씨: 됐다! 게시글 도메인 모델에서 필요한 속성은 모두 입력했어. 속성의 접근자가 private이니 적절한 메서드를 추가하자. getter setter 타닥 타닥..

멘토님: 어! 준식씨. 그렇게 세터 막 그렇게 타닥타닥 그렇게 하면 안돼요. 함부로 접근하지 못하게 하려고 private 접근자를 썼으면서 그걸 깨뜨리잖아.

준식씨: 헉! 그렇네요. 그럼 속성값을 수정할 때 세터 말고 어떻게 짜야 할까요?

 
멘토님: (가버린다.)

준식씨: ??

 
···한참 후.

 
준식씨: 대안을 모르겠는데. 그냥 세터 써야겠다. setAddress(address) 타닥 타닥····

옆집 사시는 춘자 할머니: 잉?? 아이구 총각! 개발자라는 사람이 그렇게 말여 응? 그케 아무렇게나 세터 넣어놓고 응? 그러면 안되는거여~~~ 캡슐화 몰러 캡슐화?

준식씨: 아·· 그럼 세터 말고 어떻게 해야 할까요?

 
옆집 사시는 춘자 할머니: 하이구 빨래 말려야 하는디! (공중제비를 돌며 사라지시는 할머니. ጿኈ ቼ ዽ ጿ)

준식씨: ····?????

 
···또 한참 후.

 
준식씨: 음·· setAddress···

비야네 스트롭스트롭(C++창시자): Don’t (해석: 세터 쓰면 안돼요.)

준식씨: ?????

비야네 스트롭스트롭:
I don’t know. (세터 이름을 쓰면 메서드가 어떤 의도인지 알 수 없어요.)
No OOP. (외부에서 상태를 변경할 수 있어서, 일관성을 보장할 수 없어요.)

준식씨: 알았어요··· 그래서 제가 뭘 하면 되죠···?

 
비야네 스트롭스트롭: Oh laundry! (하이구 빨래 말려야 하는디!) (공중제비를 돌며 사라지시는 비야네 스트롭스트롭. ጿኈ ቼ ዽ ጿ)

준식씨: (쭈그려 앉아 참아왔던 눈물을 터뜨린다.)


위의 이야기는 실제로 제가 getter, setter 메서드에 대해 알아볼 때 수많은 기술 블로그들로부터 받았던 느낌을 쓴 것입니다. (제 이름이 엄준식은 아닙니다.)

‘setter를 지양하라’ 라는 주제의 많은 기술 블로그들에는
필드 값을 변경하는 메서드 이름을 setXXX로 지정하면 왜 안되는지에 대한 설명은 아주 친절하게 적혀있으나, ‘그래서 setter 말고 뭘 쓰란 것이냐’에 대한 답은 너무나 부실하게 서술되어 있어요.

그와 반대로 DTO(계층간의 데이터 이동만을 위한 객체) 객체 안에는 세터 넣어도 된대. 근데 또 그 이유는 잘 안 알려주고. 꽤 해묵은 불만이었어요.

그래서 그동안 우격다짐으로 ‘엔티티 객체는 setter 넣지 말고, DTO에는 setter 넣고···’ 이렇게 암기하고 있었는데, 이번에 도메인 주도 개발 서적을 읽으면서 나름대로 공부하고 정리한 내용을 공유해 보겠습니다.

해당 내용은 최범균님의 '도메인 주도 개발 시작하기'의 내용을 바탕으로 작성된 글입니다.

···코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다.
도메인에서 사용하는 용어를 코드에 반영하지 않으면
그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다. ···

···도메인 모델의 엔티티나 밸류에 공개 set 메서드만 넣지 않아도 일관성이 깨질 가능성이 줄어든다.
공개 set 메서드를 사용하지 않으면 의미가 드러나는 메서드를 사용해서 구현할 가능성이 높아진다.
예를 들어 set 형식의 이름을 갖는 공개 메서드를 사용하지 않으면 
자연스럽게 cancel이나 changePassword처럼 의미가 더 잘 드러나는 이름을 사용하는 빈도가 높아진다.···

<최범균, 도메인 주도 개발 시작하기 >

‘setter를 지양하라’ 라는 말을 더 구체적으로 서술하면

외부에서 필드의 값을 변경하기 위해 접근할 때,
단순하게 setXXX라는 이름의 메서드 하나만 덜렁 주지 말고,
필드의 값을 변경하려는 목적을 제대로 파악하여
그 목적을 잘 표현하는 메서드를 제공해 주어라.

라고 정리할 수 있겠습니다.

그렇다면 공개 set 메서드를 사용했을 때 어떤 문제가 있는지 한번씩만 더 알아보면서,
이를 목적을 잘 표현하는 메서드로 setter를 대체했을 때 어떻게 해결할 수 있다는 것인지
간단한 예제를 통해 알아보겠습니다.

자바를 몰라도 이해할 수 있도록 신경을 쓰긴 했는데 자신은 없으니
틀리거나 이해되지 않는 부분이 있다면 알려주세요.

<참고!>
이 블로그에서 정의하는 '공개 set 메서드'의 뜻은
필드에 값을 할당하는 것으로 끝나는 공개(public) 메서드를 의미합니다.

public void setName(String name) {
	this.name = name; <<-- 값만 할당하고 끝!
}

예제

대박 간단한 예제를 가지고 이야기를 해봅시다.

‘회원’ 객체는 회원 이름과 상태 속성을 가지고 있습니다.
setState(state) 메서드는 회원의 state 값을 변경하는 ‘공개 set 메서드’입니다.

public class 회원 {
    private String name;
    private String state;

    public String getName() {
        return name;
    }

    public void setState(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }
}

회원관리 서비스 객체는 회원의 상태를 조정하여 회원을 차단하거나, vip로 승격합니다.

public class 회원관리 {
    public void blockMember(회원 회원) {
        if (회원.getName().equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        } <<-- 공통 도메인 규칙
        if (회원.getState().equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        } <<-- 회원을 차단할 때의 도메인 규칙

        회원.setState("blocked");
    }

    public void updateMemberToVip(회원 회원) {
        if (회원.getName().equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        } <<-- 공통 도메인 규칙
        if (회원.getState().equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        } <<-- vip로 상향할 때의 도메인 규칙
        회원.setState("vip");
    }
}

회원의 상태(state 필드 값)를 변경하기 위해서는 회원 도메인의 규칙을 지켜야 합니다.

  • 특정한 이름을 가진 회원의 상태는 변경할 수 없다.
  • vip는 차단할 수 없다.
  • 차단된 회원은 vip가 될 수 없다.

만약 회원이 로직을 실행하기에 부적합한 이름 또는 상태를 가지고 있다면 예외를 터뜨려 로직을 중단합니다.


공개 set 메서드의 문제점 1: 도메인 로직의 분산

공개 set 메서드는 도메인의 의미나 의도를 표현하지 못하고
도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다.

도메인 로직이 한 곳에 응집되지 않으므로
코드를 유지 보수할 때에도 분석하고 수정하는 데 더 많은 시간이 필요하다.

<최범균, 도메인 주도 개발 시작하기 >

위의 못생긴 예제를 다시 보면서 ‘도메인 로직이 응용 영역이나 표현 영역으로 분산된다’의 의미를 자세히 알아보겠습니다.

도메인 모델 패턴에서, 사용자의 요청을 처리하고 사용자에게 정보를 보여주는 영역을 표현 영역이라고 합니다. MVC패턴을 예시로 들면 흔히 http 요청을 받고 응답하는 컨트롤러 클래스들을 표현 영역으로 이해하실 수 있어요. 이 예제에는 표현 영역은 없네요!

사용자가 요청한 기능을 실행하는 영역을 응용 영역이라고 합니다. MVC 패턴을 예시로 들면 비즈니스 로직을 수행하는 서비스 클래스들을 응용 영역으로 이해하실 수 있고, 예제의 ‘회원관리’ 클래스가 응용 영역에 해당해요.

특정 도메인을 개념적으로 표현하고, 도메인의 핵심 규칙이 있는 영역을 도메인 영역이라고 합니다. 도메인 영역은 도메인 객체 하나를 가리킬 수도 있지만, 연관이 있는 상위 도메인과 하위 도메인들을 한 다발로 묶어 애그리거트(Aggregate)라는 군집을 가리킬 수도 있어요. 위 예제에서는 ‘회원’ 클래스가 도메인 영역에 해당해요.

···

‘회원의 상태를 변경하기 위해서는 회원이 특정한 이름을 가지고 있는지 확인해야 한다’ 는 규칙이 있다고 예제 설명에서 이야기 했습니다. 도메인의 속성을 변경하기 위한 핵심 규칙이기 때문에 이 규칙은 도메인 영역에서 구현되어야 맞습니다.
헌데 이 규칙을 지금 어디에서 구현하고 있나요?

public class 회원관리 {
    public void blockMember(회원 회원) {
        if (회원.getName().equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        } <<-- 도메인 규칙을 응용 영역에서 다루고 있습니다.
        if (회원.getState().equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
        회원.setState("blocked");
    }

    public void updateMemberToVip(회원 회원) {
        if (회원.getName().equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        } <<-- 도메인 규칙을 응용 영역에서 다루고 있습니다.
        if (회원.getState().equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
        회원.setState("vip");
    }
}

도메인 규칙을 표현하는 코드가 비즈니스 로직을 수행하는 '회원관리' 클래스에 작성돼 있네요. 다시말해 도메인 로직이 응용 영역으로 분산되어 있습니다.
그나마 위 예제에서는 응용 영역에서 도메인 규칙을 표현을 지키도록 구현을 해주었기 때문에 당장 큰 문제는 없을 거에요.
하지만 우리의 신입사원 엄준식씨가 입사하여 또다른 비즈니스 로직을 만든다면?

public class 회원관리 {
    public void blockMember(회원 회원) {
        if (회원.getName().equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        }
        if (회원.getState().equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
        회원.setState("blocked");
    }

    public void updateMemberToVip(회원 회원) {
        if (회원.getName().equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        }
        if (회원.getState().equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
        회원.setState("vip");
    }

    public void updateMemberToNormal(회원 회원) {
        회원.setState("normal");
    } <<-- 엄준식씨가 추가한 비즈니스 로직
}

아무때나 세터 갖다쓰는 준식씨가 과연 도메인 규칙을 잘 지키면서 로직을 짜주리라 기대할 수 있을까요?
엄준식씨의 코드는 ‘회원의 상태를 변경하기 위해서는 회원이 특정한 이름을 가지고 있는지 확인해야 한다’ 라는 규칙을 검증할 수 없습니다.
이제 updateMemberToNormal() 로직이 실행되었을 때 백여우라는 이름의 회원이 보통 등급으로 떨어져버릴 수 있고, 지구는 멸망할 것입니다.

준식씨가 회개하여 도메인 규칙을 잘 표현해 준다 한들 안심할 수 없습니다.
지금은 회원의 상태를 변경하는 로직이 3개 뿐이지만,
서비스가 커지면서 회원의 상태를 변경하는 곳이 1억 군데라면? 애플리케이션이 터지지 않기 위해 1억 군데에서 규칙을 잘 표현해 주었기를 기도해야겠죠.

게다가 회원의 상태를 변경하기 위한 규칙이 추가되거나 삭제된다면?
회원의 상태를 변경하는 1억 개의 비즈니스 로직을 하나하나 뒤져가며 규칙을 수정해야 할 것입니다.

공개 set 메서드를 사용할 때 도메인 로직이 한 곳에 응집되지 않으므로 코드를 유지 보수할 때에도 분석하고 수정하는 데 더 많은 시간이 필요하다.의 예시를 살펴보았습니다.

···

해답에 대해 알아보기 전에 딱 하나만, 딱 하나의 예시만 더 살펴보아요.

단순히 값을 변경하는 공개 set 메서드가 일으킬 문제에 대해선 알겠어요.
그럼 값을 변경하는 것에 더해서 공통 규칙을 set 메서드에 넣어주면 해결 되겠네요?

public void setState(String state) {
    if (this.name.equals("백여우")) {
        throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
    } <<-- 공통 규칙을 set 메서드에 추가
    this.state = state;
}
public class 회원관리 {
    public void blockMember(회원 회원) {
        if (회원.getState().equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
        회원.setState("blocked"); <<-- 공통 규칙은 set 메서드 안에 있습니다.
    }

    public void updateMemberToVip(회원 회원) {
        if (회원.getState().equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
        회원.setState("vip"); <<-- 공통 규칙은 set 메서드 안에 있습니다.
    }
}

와! 도메인 공통 규칙에 대한 문제점이 사라졌습니다. 해결되었네요!

자. 그럼 이제 이것들에 대해서도 논의해 볼까요?

public class 회원관리 {
    public void blockMember(회원 회원) {
        if (회원.getState().equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        } <<-- 회원을 차단할 때의 도메인 규칙
        회원.setState("blocked");
    }

    public void updateMemberToVip(회원 회원) {
        if (회원.getState().equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        } <<-- vip로 상향할 때의 도메인 규칙
        회원.setState("vip");
    }
}

‘vip는 차단할 수 없다.’ ‘차단된 회원은 vip가 될 수 없다’라는 규칙 또한 도메인 규칙이고, 도메인 영역에 있어야 합니다.
헌데 그 책임을 응용 영역에서 지고 있고, 엄준식씨가 도메인 규칙을 빼먹을 것이고, 지구가 멸망할 것입니다.

그럼 특정한 조건에 따른 도메인 규칙도 set 메서드에 넣으면 해결이 될까요?

public void setState(String state) {
    if (this.name.equals("백여우")) {
        throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
    }

    if (state.equals("blocked")) {
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
    } <<-- 회원을 차단할 때의 도메인 규칙?

    if (state.equals("vip")) {
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
    } <<-- vip로 상향할 때의 도메인 규칙?

    this.state = state;
}
public class 회원관리 {
    public void blockMember(회원 회원) {
        회원.setState("blocked");
    }

    public void updateMemberToVip(회원 회원) {
        회원.setState("vip");
    }
} // 해결???

좋지 않죠. 왜 좋지 않은가 감이 잡히지 않으신다면 두 개의 간단한 시나리오에 대해 생각해보시면 감이 오실 것 같아요.

  • 만약 회원에게 1억 개의 상태(state 값)가 존재하고, 각각의 상태로 바꿀 때마다 지켜야 하는 1억 가지의 도메인 규칙이 있다면, 그 규칙을 표현하기 위해 setState() 안에 1억 개의 도메인 규칙을 작성해야 할까요?
  • '회원의 상태를 blocked로 바꿀 때의 규칙'에 변화가 생겼습니다. 하지만 눈이 침침한 김과장님은 setState() 안에서 위치를 헷갈려 '회원의 상태를 vip로 바꿀 때의 규칙'에 손을 대 엉뚱한 규칙을 바꾸어 버렸습니다.

리팩토링 관련된 이야기에 자주 나오는 ‘하나의 공간에 너무 많은 책임’에 대한 전형적인 예시입니다. 다섯 글자로 줄이면 ‘똥냄새난다’ 라고 표현할 수 있겠습니다.

이제 해결책에 대해 이야기해 보아요!


문제점 1(도메인 로직의 분산)을 해결하는 방법

맨 위에서 제시했던 답안,

필드의 값을 변경하려는 목적을 제대로 파악하여 
그 목적을 잘 표현하는 메서드를 제공해 주어라. 

를 지키도록 메서드를 바꾸어 봅시다.

public class 회원 {
    private String name;
    private String state;

    public String getName() {
        return name;
    }

    public void blockMember() {
        verifyName();
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        } <<-- 회원을 차단할 때의 도메인 규칙

        this.state = "blocked";
    }

    public void upgradeMemberToVip() {
        verifyName();
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        } <<-- vip로 상향할 때의 도메인 규칙

        this.state = "vip";
    }

    private void verifyName() {
        if (this.name.equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        }
    } <<-- 공통 도메인 규칙

    public String getState() {
        return state;
    }
}
public class 회원관리 {
    public void blockMember(회원 회원) {
        회원.blockMember(); <<-- 도메인 관련 로직을 도메인 영역에 위임
    }

    public void updateMemberToVip(회원 회원) {
        회원.upgradeMemberToVip(); <<-- 도메인 관련 로직을 도메인 영역에 위임
    }
}

와! 무언가 바뀐 티가 나네요.
이 코드가 기존의 문제점을 어떻게 해결해 줄까요?

 

  • 공통 도메인 규칙이 응용 영역으로 분산된다

회원 도메인의 상태를 변경할 때 공통으로 필요한 도메인 규칙을 도메인 영역 안에서 구현하고 지키게 함으로써, 도메인 영역 외의 영역들에서는 더이상 공통 도메인 규칙에 대해 신경쓸 필요가 없습니다.

헌데 도메인 영역 안의 메서드라고 해도, 이런 식의 코드라면 공통 도메인 규칙을 검사하는 것을 깜빡할 수도 있을 것 같습니다.

public void upgradeMemberToVip() {
    // verifyName()을 넣는 것을 깜빡할 수도 있다!
    if (this.state.equals("blocked")) {
        throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
    }

    this.state = "vip";
}

그렇다면 코드를 한번 더 리팩토링해 봅시다.

public class 회원 {
    private String name;
    private String state;

    public String getName() {
        return name;
    }

    public void blockMember() {
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }

        setState("blocked");
    }

    public void upgradeMemberToVip() {
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }

        setState("vip");
    }

    private void setState(String state) {
        verifyName();
        this.state = state;
    } <<-- 공통 도메인 규칙과 필드에 값을 할당하는 로직을 묶어 메서드로 뺀다.

    private void verifyName() {
        if (this.name.equals("백여우")) {
            throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
        }
    }

    public String getState() {
        return state;
    }
}

이제 공통 도메인 규칙과 필드에 값을 할당하는 코드를 setState()라는 메서드로 묶어 추출했습니다. 필드에 값을 할당하고자 한다면 직접 값을 할당하지 말고 내부적으로 다시 setState()를 호출하도록 만들면, 값 할당과 함께 공통 도메인 규칙을 검사하는 로직이 무조건 함께 실행되게 돼요.

그럼 각기 다른 목적으로 필드값에 접근하는 blockMember()와 upgradeMemberToVip()는 공통 도메인 규칙(이름 검사)에 대해 신경쓸 필요가 없고, 각자의 목적에 맞는 전용 도메인 규칙(vip는 차단 불가, 차단 회원은 vip 불가)에만 신경쓸 수 있게 됩니다.

Q. 동작 그만! 저, 저거 setState() 아니여? 결국 set 메서드 쓰는구만!
A. 네 결국은 setState()가 생겼네요. 하지만 setState()의 접근자를 보십시오! 이 set 메서드는 클래스 내부에서 데이터를 변경하기 위해 private 접근자와 함께 사용되므로, 외부에서는 setState()를 사용할 수 없습니다.

 

  • 상황별 도메인 규칙이 응용 영역으로 분산된다

공통 도메인 규칙의 경우에는 공개 set 메서드를 사용하더라도 공통 도메인 로직을 set 메서드 안에 추가함으로써 해결할 수 있었으나,
각기 상황에 맞는 상황별 도메인 규칙을 공개 set 메서드에 집어넣으려 시도하자 코드의 품질이 급격히 낮아짐을 확인했습니다.

개선된 코드에서는 응용 영역에 구현했던 상황별 도메인 규칙을 도메인 영역으로 옮기되, 공개 set 메서드 하나에 집어넣는 것이 아니라 각 상황별로 각각의 공개 메서드를 만들어 그 안에 도메인 규칙을 구현하고, 그 각각의 메서드를 외부에 제공합니다.

그럼 응용 영역 입장에서는 어떤 장점이 생겼을까요?

public class 회원관리 {
    public void blockMember(회원 회원) {
        회원.blockMember();
    }

    public void updateMemberToVip(회원 회원) {
        회원.upgradeMemberToVip();
    }
}

응용 영역에서 도메인 영역과 소통할 때, 도메인 모델과 관련된 모든 규칙들은 도메인 모델 안에서 알아서 구현하도록 했기 때문에 응용 영역은 어떠한 도메인 규칙에 대해서도 전혀 신경쓸 필요가 없습니다. (캡슐화)

도메인과 관련된 어떤 처리가 필요할 때는 도메인 영역에서 제공하는 공개 메서드들 중에서 목적에 알맞는 메서드를 호출하고, 응용 영역은 비즈니스 로직에만 집중할 수 있습니다.

위 예제에서는 비즈니스 로직이 따로 없으므로 와닿지 않을 수 있으니 간단한 비즈니스 로직이 있는 코드를 살펴보면 좋을 것 같아요.

public class 보안관리 {
    @Autowired
    ArticleRepository articleRepository;

    public void supportTerrorism() {
        List<Article> articles = articleRepository.findAll();
        // 작성한 게시글을 모두 찾는다.

        List<Article> terrorArticles = articles.stream()
                .filter(article -> article.getContent().getContent().contains("테러"))
                .collect(Collectors.toList());
        // 게시글 내용 중 테러를 암시하는 게시글을 모두 찾는다.

        List<회원> terrorists = terrorArticles.stream()
                .map(article -> article.getWriter())
                .collect(Collectors.toList());
        // 테러를 암시하는 게시글을 쓴 작성자를 모두 찾는다.

        terrorists.stream()
                .forEach(terrorist -> terrorist.blockMember());
        // 각각의 작성자를 활동정지시킨다. -> 도메인 영역의 메서드를 호출하여 위임

        callThePolice();
        // 경찰에 신고한다.
    }

    private void callThePolice() {
        System.out.println("신고합니다.");
    }
}

(자바8, 또는 자바 자체를 모르시는 분들은 주석만 읽으셔도 이해가 되실 거에요!)
우리의 게시판 애플리케이션이 매우 급진적이라 이따금씩 테러범들이 글을 올리곤 해서, 테러를 암시하는 글을 찾아내 회원을 활동정지시키고 경찰에 신고하는 로직이 있다고 가정해 봅시다.
이 코드는 '테러범을 찾아내 신고한다' 라는 비즈니스 요구사항을 처리하는 비즈니스 로직이에요. 로직을 실행하는 중간에, ‘각각의 작성자를 활동정지시킨다’는 로직을 수행하기 위해 ‘회원’ 도메인 객체에 회원에 대한 차단을 해달라고 그 책임을 위임하고 있어요.

만약 도메인 영역에 위임한 이 책임을 응용 영역이 떠안는다면?

public class 보안관리 {
    @Autowired
    ArticleRepository articleRepository;

    public void supportTerrorism() {
        List<Article> articles = articleRepository.findAll();
        // 작성한 게시글을 모두 찾는다.

        List<Article> terrorArticles = articles.stream()
                .filter(article -> article.getContent().getContent().contains("테러"))
                .collect(Collectors.toList());
        // 게시글 내용 중 테러를 암시하는 게시글을 모두 찾는다.

        List<회원> terrorists = terrorArticles.stream()
                .map(article -> article.getWriter())
                .collect(Collectors.toList());
        // 테러를 암시하는 게시글을 쓴 작성자를 모두 찾는다.

        terrorists.stream()
                .forEach(terrorist -> {
                    if (terrorist.getName().equals("백여우")) {
                        throw new IllegalArgumentException("귀여운 사람의 정보는 변경할 수 없습니다.");
                    }
                    if (terrorist.getName().equals("vip")) {
                        throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
                    }
                    terrorist.setState("blocked");
                }); <<-- 도메인영역에서 처리해야 할 책임을 떠안았다.
        // 각각의 작성자를 활동정지시킨다.

        callThePolice();
        // 경찰에 신고한다.
    }

    private void callThePolice() {
        System.out.println("신고합니다.");
    }
}

비즈니스 로직에서 도메인 규칙을 떠안음
= 응용 영역으로 도메인 영역의 책임이 분산됨
= 응용 영역에 너무 많은 책임
= 똥냄새

도움이 되셨기를 바라요.


공개 set 메서드의 문제점 2: 잘못 정의한 메시지

보안과 관련하여 아주 유명한 격언을 한 번 보겠습니다.
Never Trust User(사용자를 믿지 말라)

이번에는 여기 blog라는 도메인이 있습니다.
blog의 인스턴스 객체(인스턴스를 모르신다면, 그냥 blog 하나를 만들었다 생각하시면 돼요)를 하나 만들고 .을 하나 눌렀더니 사용가능한 공개 메서드로 아래의 메서드들이 나오네요.

그렇다면 문제. blog 인스턴스 객체에 접근하는 사용자는 blog에 대해 어떤 행동을 할 수 있을까요? 모두 골라보세요.

  1. id 값을 바꾼다.
  2. title 값을 바꾼다.
  3. content 값을 바꾼다.
  4. writer 값을 바꾼다.

정답을 찾으셨나요? 1, 2, 3, 4 전부 정답일까요?

 


정답은···
‘없다. 아무것도 바꾸면 안된다.’ 입니다.

해설을 해보겠습니다.

  1. blog의 id 값은 blog 객체의 유일한 식별자입니다. id값을 바꾸기 위해서는 id를 외래키로 가지는 다른 객체들과의 관계를 검토해야 하고, 잘못된 id가 할당되지 않는지 검사해야 하는데 setId() 에는 그런 로직이 없으므로 오류가 일어날 수 있습니다. 따라서 setId() 는 사용하면 안 됩니다.
  2. title은 글의 제목이라는 뜻이므로 바꿀 수 있을 것 같지만, 사실 이 애플리케이션에서 글의 제목을 정하는 개같은 규칙이 있습니다. 영어 대/소문자, 숫자, 특수문자를 포함하여 10자 이상으로 작성해야 합니다. (예: IAmTitle0234!!@@) 하지만 setTitle() 에는 그런 로직이 없으므로 오류가 일어날 수 있습니다. 따라서 setTitle() 는 사용하면 안됩니다.
  3. content는 글의 내용이라는 뜻이므로 수정할 수 있을 것 같지만, 사실 이 애플리케이션에서 글의 내용을 수정하려면 미국 대통령의 허락을 받아야 합니다. 현재 setContent()에는 대통령의 허락을 요청하는 코드가 없으므로 사용할 수 없습니다.
  4. 사내 내규입니다. 작성자는 수정할 수 없습니다. setWriter() 는 사용하면 안됩니다.

···

다 틀리셨네요. 당신이 그러고도 개발자인가요?

그렇죠. 황당한 게 정상이죠.
도메인 안의 필드 각각에 무슨 규칙이 있는지 전혀 안 알려주었으니 모르는 게 당연하죠.

그보다 근본적인 이유는, 4개의 공개 set 메서드를 만들어 둚으로써, blog 도메인을 설계한 제가 id, title, content, writer 필드값을 수정할 수 있도록 허용해 주었으니까요. 사용자 입장에서 이 코드를 볼 때 ‘아 이건 수정할 수 있나보다’ 생각하는 게 당연한 것 아닌가요?

조영호님의 저서 ‘객체지향의 사실과 오해’ 라는 책에서는 책 전반에 걸쳐 객체와 객체 간에 주고받는 메시지의 역할을 강조합니다.

메시지를 수신받은 객체는 우선 자신이 해당 메시지를 처리할 수 있는지 확인한다.
메시지를 처리할 수 있다는 이야기는 객체가 해당 메시지에 해당하는 행동을 수행해야 할 책임이 있다는 것을 의미한다.
따라서 근본적으로 메시지의 개념은 책임의 개념과 연결된다.
송신자는 메시지 전송을 통해서만 다른 객체의 책임을 요청할 수 있고, 수신자는 오직 메시지 수신을 통해서만 자신의 책임을 수행할 수 있다.
따라서 객체가 수신할 수 있는 메시지의 모양이 객체가 수행할 책임의 모양을 결정한다.

<조영호, 객체지향의 사실과 오해 >

객체가 객체에게 어떤 행동을 지시하는 유일한 방법은 메시지를 주고받는 것입니다.
메시지를 받을 수신자 객체가 ‘나 이런 일 할 수 있어요’ 라고, 자신이 받을 수 있는 메시지를 정의해두면,
발신자 객체는 수신자 객체가 공개한 메시지들을 보고 ‘아, 얘는 이런 것들을 할 수 있구나' 를 파악하고 그 메시지에 맞추어 요청을 보내는 방식으로 소통하는 거에요.

위의 내용을 바탕으로 우리의 문제가 왜 발생했는지 분석해 봅시다.
blog 객체는 setId(), setTitle(), setContent(), setWriter() 라는 공개 set 메서드를 가지고 있는데, 이들은 각각 ‘id를 바꾼다’, ‘title를 바꾼다’, ‘content를 바꾼다’, ‘writer를 바꾼다’ 라는 메시지를 받아 수행할 수 있다는 의미입니다.

그럼 바깥에서 blog 객체를 사용하는 다른 사용자들, 즉 다른 송신자 객체들은 blog 객체에서 공개한 메서드들을 보고 ‘아, 얘는 id, title, content, writer를 바꿀 수 있구나’ 라고 인식하고 그 메서드들을 실행할 수가 있겠죠.

하지만 실제로는 그 메서드들을 실행했을 때 문제가 생길 수 있음에도 실행 권한을 주어 버렸으므로 문제가 발생하는 것입니다.

···

그렇다면 코드를 어떻게 개선해야 할까요?


문제점 2(잘못 정의한 메시지)을 해결하는 방법

사실 맨 위에 있는 책 인용문에 해결방법이 정확히 새겨져 있었어요. 핵심 문장만 떼서 다시 읽어볼게요.

도메인 모델의 엔티티나 밸류에 공개 set 메서드만 넣지 않아도 일관성이 깨질 가능성이 줄어든다. 
공개 set 메서드를 사용하지 않으면 의미가 드러나는 메서드를 사용해서 구현할 가능성이 높아진다.
예를 들어 set 형식의 이름을 갖는 공개 메서드를 사용하지 않으면 
자연스럽게 cancel이나 changePassword처럼 의미가 더 잘 드러나는 이름을 사용하는 빈도가 높아진다.

저같은 취준생을 포함해서, 많은 초보 개발자들이 도메인 클래스를 만들 때 반자동적으로 getter/setter 메서드를 뚜드려 넣어 버립니다.

intellij, VS code 같은 도구에서 getter, setter를 편하게 작성할 수 있는 기능들을 지원하고, 자바의 경우에는 lombok을 이용해 간편하게 메서드 자동 생성이 가능하기 때문에 습관적으로 공개 get, set 메서드들을 만들어 두는 거에요.

이 습관을 버리는 것이 가장 확실한 해결책입니다. 책이나 강의 예제에서 편의를 위해 getter, setter를 넣는걸 봤다고 해서 우리의 프로젝트에도 대충 getter setter 타닥 타닥 뚜드리지 말라는 것이에요!

객체의 필드 값을 조회/수정하는 목적은 무엇인지, 이 때 지켜야 하는 규칙 등을 명확히 파악한 후 꼼꼼하게 설계된 메서드를 공개하면 공개 set 메서드로 인한 문제들을 예방할 수 있습니다.

···

위 예제를 처음부터 다시 설계하면서 감을 잡아 봅시다.

public class Blog {
    private Long id;
    private String title;
    private String content;
    private 회원 Writer;
}

일단 습관적으로 적어놓은 공개 get, set 메서드를 날려 버립시다.
blog라는 객체에서 어떤 메시지를 정의해야 할지를 도메인 규칙을 바탕으로 고민해 보는 겁니다.

  1. id는 유일한 식별자입니다. 통상적으로 식별자 값은 데이터의 무결성과 연관관계 등 고려해야 할 제약조건이 많아 수정하지 않는 것을 권장하니, id 값을 바꾸는 코드는 없어도 될 것 같습니다.
  2. title을 바꾸기 위해서는 바꾸려는 새 title이 ‘영어 대/소문자, 숫자, 특수문자를 포함하여 10자 이상` 을 만족하는 지 검사해야 합니다.
    검사를 마친 후 title을 바꾸도록 메서드를 구현하고, setTitle()보다 실행 의도을 분명하게 나타내도록 changeTitle() 이라는 메서드명으로 정의하면 좋을 것 같습니다.
  3. content를 수정하기 위해서는 미국 대통령의 허락을 받아야 합니다. 대통령의 허락을 구한 후 content값을 수정하도록 메서드를 구현하고, setContent() 보다 실행 의도을 분명하게 나타내도록 changeContent() 라는 메서드명으로 정의하면 좋을 것 같습니다.
  4. writer의 값을 바꾸는 것은 금지되어 있습니다. 관련 메서드를 제공하지 않겠습니다.

위를 바탕으로 blog의 메시지를 표현하는 메서드를 짜면 다음과 같습니다.

public class Blog {
    private Long id;
    private String title;
    private String content;
    private 회원 Writer;

    public void changeTitle(String newTitle) {
        checkTitle(newTitle); // title의 형식이 올바른지 검증합니다
        this.title = newTitle;
    } <<-- title 필드의 값을 변경하는 로직

    private void checkTitle(String title) {
        Pattern titlePattern = Pattern.compile("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&])[A-Za-z[0-9]$@$!%*#?&]{10,}$");
        Matcher matcher = titlePattern.matcher(title);

        if (!matcher.find()) {
            throw new IllegalArgumentException("title의 작성 양식이 틀리다.");
        }
    }

    public void changeContent(String newContent) {
        askPresident(newContent); // 대통령의 허락을 구합니다
        this.content = newContent;
    } <<-- content 필드의 값을 변경하는 로직

    private void askPresident(String newContent) {
        if (!JoeBiden.isContentAcceptable(newContent)) {
            throw new IllegalArgumentException("대통령이 거부하셨다.");
        }
    }
    
    <<-- id, writer 값을 수정하는 메서드는 제공하지 않습니다
}

각각의 코드가 읽기에 약간 어려워졌지만 훨씬 구체적이에요! 그리고 이 메서드들은 blog 객체의 규칙과 데이터 일관성이 훼손될 가능성을 현저히 줄인 안전한 코드입니다.

이전 코드와 개선된 코드를 비교해본 후 글을 마무리 하겠습니다.

  • 도메인 규칙상 값이 수정되면 안되는 id, writer를 수정하는 메서드를 제공하지 않으므로 두 필드의 안전이 보장되었습니다.
  • 단순히 필드에 값을 할당하는 공개 set 메서드를 외부에서 사용하면 외부에서 이 메서드가 가지는 의미나 의도를 파악하기 힘들고, 그로 인해 도메인이 지켜야 하는 데이터의 일관성과 규칙을 무시하고 사용함으로써 치명적인 오류를 일으킬 수 있습니다.
    따라서 공개 set 메서드가 아니라 ‘내용을 변경한다’ 라는 의미를 분명하게 갖는 changeTitle(), changeContent() 라는 메서드명을 사용하고, 상태값 변경 이전에 도메인 규칙을 검증하는 코드를 내부에 함께 구현하여 메서드를 호출하더라도 데이터의 일관성과 규칙이 깨지지 않도록 안전한 메서드를 제공합니다.

마무리하며

사실 글의 내용은 별거 없습니다. 너무 포괄적이고 제약이 없는 메서드를 내어주면 위험하니 구체적이고 안전한 메서드를 만들어 내어주면 된다는 이야기를 21000자를 들여 길게 늘어뜨려 보았어요.

저는 취준생이고 다른 사람과 협업하면서 코딩을 해본 적이 없어요.
기술 스택들을 뚱땅뚱땅 배워서 어떻게 운좋게 취업을 한다 해도 혹여 제가 다른 팀원들에게 피해를 주지 않을까, 과연 도움이 되는 사람이 될 수 있을까를 많이 걱정하고 있습니다.

이번 공개 set 메서드에 대한 이야기도, ‘내가 만든 코드를 팀원들이 가져다 사용할 때 문제가 생기지 않게 하려면 어떻게 해야 할까?’ ‘유지보수가 쉬운 코드를 만들어서 팀원들이 더 편하게 일할 수 있으으면 좋겠다’ 라는 바람에서 출발해 공부하고, 한 번 기록해본 거에요.

어쩌면 실무경험이 많으신 분들께서는 ‘아 그냥 세터 쓰는게 편한데 크크루삥뽕’ 하실지도 모르겠어요. 만약 그러시다면··· 면목없습니다···

나름 쉽게 써보려 노력했는데 잘 읽혔으면 좋겠어요.
읽어주셔서 고마워요. 안녕.

profile
그 때의 나에게. 단지 너를 지켜왔다는 것. 그 하나만으로도 수고 많았다.

7개의 댓글

comment-user-thumbnail
2022년 8월 1일

재밌게 잘 봤습니다! ㅎㅎ 👍

답글 달기
comment-user-thumbnail
2022년 8월 2일

ㅋㅋㅋ 글 재미있네요
웃으면서 잘 읽었습니다

답글 달기
comment-user-thumbnail
2022년 8월 8일

재밌게 잘봤씁니다.

답글 달기
comment-user-thumbnail
2022년 8월 9일

재밌습니다.

답글 달기
comment-user-thumbnail
2022년 8월 10일

너무 재밌고 유익합니다! ㅋㅋ

답글 달기
comment-user-thumbnail
2022년 8월 11일

👍 재밌게 잘 봤어요~!

답글 달기
comment-user-thumbnail
2022년 8월 11일

지구가 멸망하기 전에 많은 개발자분들이 꼭 읽어야겠네요. 잘봤습니다.

답글 달기