객체지향 설계와 도메인-주도 설계에 관심이 많으며 행복한 팀과 깔끔한 코드, 존중과 협력이 훌륭한 소프트웨어를 낳는다는 믿음을 가지고 있는 평범한 개발자입니다. 개발자, 교육자, 관리자를 오가며 익힌 다양한 경험을 바탕으로 좋은 코드와 함께 좋은 프로덕트를 만들기 위해 노력하고 있습니다.
저서로는 『객체지향의 사실과 오해』와 『오브젝트』가 있고 번역서로는 『엘레강트 오브젝트』가 있으며 『만들면서 배우는 클린 아키텍처』에 감수자로 참여했습니다.
💡개인블로그 : https://eternity-object.tistory.com/
Courses
Reviews
- Object - Design Principles
- Objects - Basics
- Objects - Basics
Posts
Q&A
질문 있습니다!
거북이님 좋은 질문 남겨 주셔서 감사합니다.강의에서 말씀 드렸던 것처럼 객체지향에서 모든 것은 ‘행동(또는 책임)’과 관련이 있습니다.따라서 객체지향에서 제일 먼저 고민할 부분은 어떤 책임을 어떤 객체에게 할당할 것인지를 결정하는 거죠.그 과정에서 할인 여부를 판단할 책임을 도메인 모델 상에서 할인 조건에 해당하는 DiscountCondition에 할당하면 어떨까 고민하고 있다고 해보겠습니다. 여기까지는 DiscountCondition이 외부에 제공할 행위, 즉 책임을 결정한 거죠.이제 외부에 제공할 행동은 결정했으니 그 다음으로는 DiscountCondition이 할인 여부를 판단하기 위해 코드를 어떤 식으로 구현할 지를 구체적으로 고민하게 됩니다.기간 조건에서는 ‘상영 시간’이 필요하고, 순번 조건에서는 ‘상영 회차’가 필요합니다. 여기서 ‘상영 시간’과 ‘상영 회차’는 ‘데이터’가 아니라 ‘정보(information)’라는 사실이 중요합니다.강의에서 말씀드린 것처럼 정보는 행동과 관련이 있기 때문에, DiscountCondition을 구현하기 위해서는 상영 시간과 상영 회차를 물어보기에 가장 적절한 객체를 찾아야 합니다.당연히 Screening이 되겠죠. 여기까지 Screening에게 상영 시간과 상영 회차를 물어본 후에 이렇게 얻은 정보를 DiscountCondition에서 사용한다는 부분까지는 결정했습니다.이제 DiscountCondition에서 이 정보에 접근할 수 있는 방법을 결정해야겠죠.이 정보를 제공할 책임은 Screening에게 있기 때문에 가장 간단한 방법은 Screening 인스턴스를 DiscountCondition의 isSatisfiedBy 오퍼레이션의 파라미터로 전달하는 겁니다.결과적으로 Movie의 calculateFee 메서드에 Screening 인스턴스를 전달해서 DiscountCondition까지 전달되도록 만들어야겠죠.이렇게 설계를 할 때는 항상 응집도와 결합도 관점에서 설계를 검토해봐야 합니다. 만약 Screening에서 시작해서 DiscountCondition까지의 전체 클래스가 함께 수정되는 경향이 있다면 강의에서 사용한 예제처럼 Screening을 DiscountCondition까지 전달해도 무방합니다.강의에서는 강의를 최대한 단순화시키기 위해 함께 변경한다고 가정하고 동일한 패키지 안에 클래스들을 포함시키기 때문에 Screening에 대한 결합도가 높아도 무방하다고 가정하고 있습니다.만약 이 결합도가 문제가 된다면 정보를 DiscountCondition에 전달하면서도 Screening에 직접 의존하지 않도록 만드는 방법을 찾는게 좋겠죠.예를 들면 Screening을 직접 전달하는 대신 필요한 정보를 별도의 객체에 담아 전달해서 Screening에 대한 의존성을 낮출 수 있습니다.아래 코드를 보시면 Screening 내부의 내용을 ScreeningCondition으로 변환한 후에 이 정보를 Movie에 전달하고 있습니다.public class Screening { private Long id; private Long movieId; private Integer sequence; private LocalDateTime screeningTime; public class Screening { public Reservation reserve(Long customerId, Movie movie, int audienceCount) { if (!movieId.equals(movie.getId()) { throw new IllegalArgumentException(); } Money fee = movie.calculateFee(toScreeningCondition()).times(audienceCount); return new Reservation(customerId, this.id, audienceCount, fee); } public ScreeningCondition toScreeningCondition() { return new ScreeningCondition(sequence, screeningTime); } ... } 위 코드는 다른 분의 질문에 삽입된 코드인데 자세한 내용이 궁금하시면 아래 글을 참고 부탁드릴게요.https://inf.run/kqT92 결과적으로 책임을 할당한다는 것은 그 객체가 수행해야 하는 책임을 할당하면서 동시에 그 책임을 수행하기 위해 다른 객체에게 어떤 정보를 요청해야 하는 지를 결정하는 과정입니다.이 과정에 클래스의 응집도가 높아지고 결합도가 낮아지도록 협력 방식을 조율해야 합니다.이를 위해 필요한 코드 레벨에서의 가이드는 “오브젝트 - 설계 원칙편”에서 좀 더 상세히 다루고 있습니다.감사합니다. 🙂
- 1
- 2
- 29
Q&A
디자인 패턴에 대해선 어떤 생각을 가지고 계신지 궁금합니다.
크리스팍님 좋은 질문 남겨 주셔서 감사합니다. 🙂 설계가 변경과 관련이 있다는 사실을 떠올려보시면 설계 원칙과 디자인 패턴이 밀접하게 연관되어 있다는 사실을 알 수 있습니다. 먼저 설계 원칙은 다음의 두 가지 용도로 활용할 수 있습니다.요구사항이 변경될 때 코드의 어떤 부분이 변경에 부적합한 지를 판단하는 가이드로 활용할 수 있습니다. 이 부분은 새로 코드를 구현하는 시점이나, 기존 코드를 리팩터링하는 시점이나 동일한 가이드를 적용할 수 있는데, 설계 원칙을 따르는게 변경에 적합한지 여부를 결정해야 합니다.코드를 리팩터링할 때 설계를 어떻게 변경하면 좋은 지에 대한 방향성을 알려줍니다. SRP는 코드를 변경에 따라 분리하도록 만들고, DIP는 변경을 기준으로 추상화를 도입하게 만듭니다. 설계 원칙에 따라 판단하고 리팩터링하면 코드가 적절한 위치를 찾아가면서 현재의 변경을 수용하기에 적합한 역할, 책임, 협력이 자리를 잡게 됩니다.다양한 기능을 구현하거나 다양한 시스템을 구현하다 보면 코드를 유연하게 만들어야 하는 원인이 유사할 때 유사한 방식으로 코드를 리팩터링하게 된다는 사실을 발견하게 됩니다.예를 들어서 요구사항이 변경돼서 다양한 종류의 할인 정책을 동적으로 선택해야 하는 상황과, 다양한 영속성 메커니즘이 필요해져서서 여러가지 영속성 스토리지를 선택해야 하는 상황은 변경이라는 관점에서 보면 동일한 요구사항을 처리할 수 있는 다양한 알고리즘 중에 하나를 선택한다는 공통점이 있습니다.두 가지 모두 설계 원칙 관점에서 코드를 리팩터링하다 보면 유사한 역할, 책임, 협력으로 구성된 유사한 설계를 얻게 되는 경우가 많은데, 이렇게 유사한 문제를 해결하다 보면 “이런 변경에는 이런 식으로 코드를 설계하면 된다”는 일종의 경험치가 쌓이게 됩니다. 이렇게 유사한 변경이 반복적으로 발생할 때 매번 설계 원칙에 따라 역할, 책임, 협력을 결정하는게 아니라, 중간 단계를 생략하고 미리 알고 있는 역할, 책임, 협력의 템플릿을 가져다가 이를 기반으로 코드를 구현할 수 있는데, 이렇게 재사용 가능한 설계(코드가 아니라)를 디자인 패턴이라고 부릅니다. 다음은 "GoF의 디자인 패턴(https://product.kyobobook.co.kr/detail/S000001962303)" 서론에서 발췌한 부분인데 변경이라는 관점에서 아래 글을 읽어보시면 도움이 되실거에요. 전문가들이 초보자들처럼 하지 않는 것이 한 가지 있다면, 모든 문제를 처음 기초 단계에서 해결하려 하지 않는다는 것이다. 대신, 전에 사용했던 해결책을 다시 사용해 본다. 그리고 좋은 방법을 찾아냈다면 그 방법을 반복해서 계속 사용하게 된다. 이런 경험을 통해 전문가가 만들어지고, 결국에는 많은 객체지향 시스템들에서 클래스 패턴이나 객체들 간의 상호작용 방식이 반복됨을 알게 된다. 이런 반복 패턴들은 특정 설계 문제점들을 해결해 주고, 좀 더 유연하고, 근사하고, 재사용 가능한 객체지향 소프트웨어를 만들어준다.… 디자인 패턴은 재사용 가능한 객체지향 설계를 만들기 위해 유용한 공통의 설계 구조로부터 중요 요소들을 식별하여 이들에게 적당한 이름을 주고 추상화한 것이다. 디자인 패턴은 패턴에 참여하는 클래스와 그들의 인스턴스를 식별하여 역할을 정의하고 그들 간의 협력 관계를 정의하고 책임을 할당한다. 결과적으로 현재 변경을 목표로 설계를 할 때 어떤 디자인 패턴을 적용하는게 적합하다고 판단된다면(이 부분은 단순히 기술적인 문제가 아니라 상황에 따라 복잡도가 올라가는지를 판단할 수 있는 경험이 필요합니다) 디자인 패턴을 적용하는 것을 목표로 코드를 구현합니다.하지만 디자인 패턴은 꼭 필요할 때만 사용하는게 좋기때문에 애매하다면 일단 디자인 패턴을 고려하지 않고 코드를 구현한 다음에 실제로 구현된 코드를 보면서 설계 원칙을 기반으로 코드를 리팩터링하거나 디자인 패턴을 적용하는게 적합하다고 판단될 경우에만 디자인 패턴을 목표로 리팩터링하시는게 좋습니다.이 부분에 대해서는 "패턴을 활용한 리팩터링(https://product.kyobobook.co.kr/detail/S000001469867)"을 읽어 보시면 큰 도움이 되실거에요. 🙂 답변이 되었는지 모르겠네요.감사합니다.
- 2
- 1
- 43
Q&A
[강의 자료 오타 제보]
거북이님 안녕하세요. 말씀하신 부분인 6-5. 설계 평가하기에서 말씀하신 부분을 확인해보니 말씀하신 것처럼 "높은 결합도"로 번역하는게 맞습니다.제가 강의 자료를 검토하면서 이 부분이 높은 응집도로 되어 있는 부분을 미처 확인하지 못했네요. 무심코 넘어갈 수 있는 부분까지 세세하게 확인하시고 피드백 주셔서 감사합니다!최대한 빠른 시간 내에 동영상 수정한 후에 공유드리도록 하겠습니다. 🙂 정말 감사 드리고 오늘 하루 행복하게 마무리하세요!
- 1
- 2
- 23
Q&A
8-5 책임 분리를 통한 중복 코드 제거 과정에서 특정 조합은 불가능할 때
hello님 안녕하세요.좋은 질문 남겨 주셔서 감사합니다. 🙂 컴파일타임에 의존성 주입 가능한 조합을 체크하는 방법 중에서 가장 간단한 방식은 파라미터의 타입을 명시적으로 드러내서 주입받을 수 있는 타입의 종류를 제한하는 것입니다.간단하게 말해서 DatabaseReader와 RedisReade의 생성자에서 조합 가능한 Parser의 타입을 전달받는 생성자들을 추가하는 방식이죠.(DatabaseReader, XmlParser)는 불가능하고 (DatabaseReader, CsvParser)와 (DatabaseReader, JsonParser)는 가능한 경우public class DatabaseReader extends AbstractReader { public DatabaseReader(String path, CsvParser parser) { super(path, parser); } public DatabaseReader(String path, JsonParser parser) { super(path, parser); } ... } (RedisReader, XmlParser)와 (RedisReader, CsvParser)는 불가능하고 (RedisReader, JsonParser) 가능public class RedisReader extends AbstractReader { private JedisPool jedisPool; public RedisReader(String path, JsonParser parser, JedisPool jedisPool) { super(path, parser); this.jedisPool = jedisPool; } } 생성자를 private으로 바꾸고 public static 생성 메서드를 추가해도 동일한 효과를 얻을 수 있습니다.이 방식의 단점은 새롭게 조합 가능한 Parser를 추가하게 되면 Reader 들의 생성자를 함께 수정해야 한다는 점인데, 전체적인 복잡도 관점에서 보면 수용할만한 정도의 단점이라고 생각합니다. 만약 새로운 Parser가 추가됐을 때 Reader를 수정하고 싶지 않다면 마커 인터페이스(marker interface)를 사용하는 방법도 있습니다.DatabaseReader와 조합 가능한 Parser 인터페이스를 제한하기 위해 Parser를 상속받는 DatabaseParsable 인터페이스를 추가합니다.public interface DatabaseParsable extends Parser {} RedisReader와 조합 가능한 Parser 인터페이스를 제한하기 위해 RedisParsable 인터페이스를 추가합니다.public interface RedisParsable extends Parser {} DatabaseReader는 DatabaseParsable만 조합할 수 있도록 제한합니다.public class DatabaseReader extends AbstractReader { public DatabaseReader(String path, T parser) { super(path, parser); } ... } RedisReader는 RedisParsable만 조합할 수 있도록 제한합니다.public class RedisReader extends AbstractReader { private JedisPool jedisPool; public RedisReader(String path, T parser) { super(path, parser); } public RedisReader(String path, T parser, JedisPool jedisPool) { super(path, parser); this.jedisPool = jedisPool; } ... } DatabaseReader와 조합 가능하지만 RedisReader와는 조합할 수 없는 CsvParser는 DatabaseParsable 인터페이스만 구현합니다.public class CsvParser implements DatabaseParsable { ... } DatabaseReader와 RedisReader 양쪽 모두와 조합할 수 있는 JsonParser는 DatabaseParsable와 RedisParsable 인터페이스 양쪽 모두를 구현합니다.public class JsonParser implements DatabaseParsable, RedisParsable { ... } 이 방식은 생성자를 이용하는 방식과 반대로 Parser가 추가될 때 Reader 계층 클래스를 수정할 필요가 없지만, Reader가 추가되면 조합 방식에 따라 Parser 계층 클래스를 수정해야 합니다. 개방-폐쇄 원칙 관점에서 Reader 계층과 Parser 계층 모두를 다 폐쇄시키는 어렵기 때문에 어떤 쪽이 더 중요한 변경의 축인지에 따라 적합한 방식을 선택하시면 될것 같아요.클래스 계층이 너무 많아져서 이 제약을 클래스 계층 전반에 걸쳐 명시하는게 점점 복잡해진다면 컴파일타임이 아닌 런타임에 제한하는 쪽으로 구현하시는게 전체적인 복잡성을 낮추는 방법일 수 있습니다. 답변이 되었는지 모르겠네요. 😊감사합니다.
- 2
- 2
- 314
Q&A
간접 참조에 대한 질문
박명규님 안녕하세요.좋은 질문 남겨주셔서 감사합니다. 간접참조를 사용하는 방식은 도메인 주도 설계(DDD)에서 애그리게이트(Aggregate) 사이의 직접적인 객체 참조 대신 서로의 식별자만 참조해서 결합도를 낮추는 방법입니다.질문의 요지가 “간접참조를 사용할 경우의 객체지향적인 설계 방법”을 문의하셨는데 사실 간접 참조를 사용하는 방식은 객체지향 설계의 일부 제약을 완화하게 됩니다.따라서 객체지향의 설계에 객체 참조를 사용할 경우에는 객체지향 원칙과는 조금 다르게 DDD의 규칙에 따라 설계를 진행하게 됩니다.질문으로 돌아와서 Screenig에 movieId를 참조할 경우 몇가지 해결 방법이 있습니다. 1. Movie 인스턴스 전달Screening에 Reservation을 생성하는 책임을 그대로 둘 경우 아래 처럼 movieId에 해당하는 Movie를 전달할 수 있습니다.이 Movie는 질문에서 언급하신 것처럼 서비스 레이어에서 DAO를 이용해서 조회한 후에 전달하면 됩니다.public class Screening { private Long id; private Long movieId; private Integer sequence; private LocalDateTime screeningTime; public class Screening { public Reservation reserve(Long customerId, Movie movie, int audienceCount) { if (!movieId.equals(movie.getId()) { throw new IllegalArgumentException(); } Money fee = movie.calculateFee(toScreeningCondition()).times(audienceCount); return new Reservation(customerId, this.id, audienceCount, fee); } public ScreeningCondition toScreeningCondition() { return new ScreeningCondition(sequence, screeningTime); } ... }이 방식은 주의할 부분이 있는데 DDD에서는 단일 트랜잭션 안에서 하나의 애그리게이트만 수정해야 하기 때문에 Movie의 상태를 변경해서는 안됩니다.따라서 Movie의 메서드 중에서 부수 효과가 없는 메서드만 사용해야 하는데 메서드를 주의해서 사용하거나 CQS(명령-쿼리 분리) 원칙에 따라 구성할 필요가 있습니다.여기에서는 Screening이 Reservation에도 결합되게 되는데 이 의존성이 문제가 된다면 이어지는 2번처럼 생성 책임을 외부로 뺄 수 있습니다. 2. Screening과 Movie의 의존성은 낮추고 Movie에게 값 객체 전달1번의 이슈가 문제가 되는 경우에는 Screening과 Movie를 직접 의존하게 만들지 않고 둘 사이의 협력을 애플리케이션 서비스인 ReservationService에서 조율하는 방법을 사용합니다.이 경우 결합도를 낮추기 위해 Movie에서 사용하는 Screenin의 데이터는 값 객체로 변환해서 Movie의 calculateFee 메서드에 전달합니다.@Service public class ReservationService { @Transactional public Reservation reserveScreening(Long customerId, Long screeningId, Integer audienceCount) { Customer customer = customerRepository.findById(customerId).get(); Screening screening = screeningRepository.findById(screeningId).get(); Movie movie = movieRepository.findAggregateById(screening.getId()).get(); Money fee = movie.calculateFee(screening.toScreeningCondition()); Reservation reservation = new Reservation(customer.getId(), screening.getId(), audienceCount, fee); reservationRepository.save(reservation); return reservation; } } 지금까지 설명드린 것처럼 DDD의 애그리게이트는 일관성 경계를 위해 객체 사이의 협력을 제한하는 경향이 있기 때문에 간접참조를 활용하는 방법은 객체지향의 설계 원칙을 트레이드오프하게 됩니다.개인적으로는 부수효과를 잘 통제한다면 1번처럼 파라미터로 전달하는 방식도 가능하다고 보고 있습니다.하지만 2번 방식처럼 서로 참조하지 않도록 만들 수 있다면 이 방식을 따르는게 더 좋은 방법이겠죠.여기에서 설명드린 부분에 대한 자세한 내용이 궁금하시다면 도메인 주도 설계의 전술적 설계 부분을 깊이 있게 학습하시는 것을 추천드립니다. 관련해서 궁금한 부분에 대해 추가로 질문해 주시면 좀 더 자세히 설명드릴게요. 😊감사합니다.
- 1
- 2
- 63
Q&A
인스턴스 증가에 대한 우려
hello님 안녕하세요.강의 들어주시고 좋은 질문도 남겨 주셔서 감사합니다. 🙂두 질문에 대해 차례대로 답변 드리도록 할게요! 1.ObjectMapper를 싱글턴으로 만들지 여부강의에서 사용한 예제 코드의 경우에는 단일 책임 원칙에 초점을 맞추고 예제의 구현을 단순화하기 위해 메서드 내부에서 ObjectMapper를 생성하고 있지만, 다수의 요청을 동시에 처리해야 하는 실무 코드에서는 ObjectMapper를 생성한 후에 필요한 곳에서 의존성 주입을 받아 사용하시는게 좋습니다.사이즈가 작고 단순한 객체라면 메서드 내부에서 지역 객체로 생성하거나 인스턴스 변수로 선언하고 매번 생성해도 무방하지만 ObjectMapper처럼 객체 초기화 비용이 큰 경우에는 하나의 인스턴스를 공유해서 사용하는게 효과적입니다.이렇게 여러 객체가 하나의 인스턴스를 공유해서 사용하기 위해서는 이 인스턴스가 쓰레드에 안전(thread-safe)해야 하는데, 다행히도 ObjectMapper의 경우에는 쓰레드에 안전하게 설계되어 있습니다.따라서 여러 인스턴스가 ObjectMapper를 공유해서 쓰더라도 문제가 발생하지 않습니다.다만 ObjectMapper의 상태를 설정하는 setter 계열 메서드는 쓰레드에 안전하지 않기 때문에 애플리케이션이 구동되는 시점에 필요한 상태를 가지도록 ObjectMapper를 초기화하고, 애플리케이션이 실행되는 동안에는 ObjectMapper의 상태를 바꾸지 말아야 합니다.Spring을 사용할 경우에는 애플리케이션 구동 시점에 아래 코드처럼 필요한 상태를 가지는 ObjectMapper 싱글톤을 생성해서 애플리케이션 컨텍스트에 등록한 후 필요한 곳에서 의존성 주입해서 사용하는 방식으로 구현합니다(Boot의 경우에는 기본 ObjectMapper가 미리 등록되어 있어 빈 등록 과정 없이 의존성 주입 받아 사용할 수 있습니다).@Configuration public class ObjectMapperConfig { @Bean // ObjectMapper 객체 빈 등록 , 빈의 이름은 메소드 이름을 따라간다 public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.registerModule(new JavaTimeModule()); // 필요한 설정 추가 ... return mapper; } } 2.인스턴스 변수와 메서드 파라미터의 차이첫 번째 차이점은 객체 인스턴스의 의미가 다르다는 점입니다.질문에 적어주신 코드를 예로 들어서 어떤 의미적인 차이가 있는지 설명해 드릴게요.먼저 dayOfWeek을 파라미터로 받는 WeeklyMenu는 “모든 요일의 메뉴 정보를 다 관리하는 책임”을 맡고 있는 객체를 의미합니다.따라서 이 객체는 어떤 요일의 메뉴를 물어보더라도 다 대답을 할 수 있죠.public class WeeklyMenu { public String getMenu(DayOfWeek dayOfWeek) { return switch (dayOfWeek) { case MONDAY -> "짜장면"; case TUESDAY -> "짬뽕"; case WEDNESDAY -> "제육덮밥"; case THURSDAY -> "볶음밥"; case FRIDAY -> "간짜장"; case SATURDAY -> "미니탕수육"; case SUNDAY -> "잡채밥"; }; } } 반면에 dayOfWeek을 인스턴스 변수로 가지는 WeeklyMenu는 “특정한 하나의 요일의 메뉴 정보만 관리하는 책임”을 맡고 있는 객체를 의미합니다.public class WeeklyMenu { private final DayOfWeek dayOfWeek; public WeeklyMenu(DayOfWeek dayOfWeek) { this.dayOfWeek = dayOfWeek; } public String getMenu() { return switch (this.dayOfWeek) { case MONDAY -> "짜장면"; case TUESDAY -> "짬뽕"; case WEDNESDAY -> "제육덮밥"; case THURSDAY -> "볶음밥"; case FRIDAY -> "간짜장"; case SATURDAY -> "미니탕수육"; case SUNDAY -> "잡채밥"; }; } } 만약 이 객체를 다음과 같이 생성했다면 이 객체는 월요일의 메뉴에 대해서만 대답할 수 있습니다.WeeklyMenu menu = new WeeklyMenu(DayOfWeek.MONDAY); 다시 말해서 파라미터로 받는 WeeklyMenu는 “모든 요일의 메뉴”를 의미하고, 인스턴스 변수로 가지는 WeeklyMenu는 “특정한 요일의 메뉴”를 의미합니다.이 의미적인 차이는 코드의 의도를 전달할 때 매우 큰 차이를 보입니다.두 번째 차이점은 방금 전에 설명드린 의미적인 차이점으로부터 파생되는 내용인데, 파라미터로 받는 WeeklyMenu는 어떤 요일의 메뉴 정보가 필요한지를 클라이언트가 판단하고 결정합니다.즉, 요일을 관리할 책임이 클라이언트에게 있고, WeeklyMenu는 클라이언트가 원하는 요일의 메뉴를 전달해주기만 하면 됩니다.반면에 인스턴스로 가지는 WeeklyMenu의 경우에는 요일을 관리할 책임이 WeeklyMenu에게 있습니다.클라이언트는 어떤 요일인지 모르는 상태에서 WeeklyMenu가 반환해주는 메뉴를 받기만 합니다.따라서 변화하는 값(이 경우에는 요일)을 클라이언트가 알고 요청해야 하는지, 아니면 그 값이 객체의 본질적인 의미여서 클라이언트는 단지 객체가 알아서 판단해주기를 바라는지에 따라인스턴스 변수를 사용할지, 파라미터를 사용할지를 결정하게 됩니다. 답변이 되었는지 모르겠네요. 😊
- 2
- 2
- 71
Q&A
6-2. 명령과 쿼리 분리 원칙 질문
강명덕님 안녕하세요.두 가지 질문에 대해 차례대로 답변을 드릴게요. 1. Game에서 if 분기를 사용한 이유강의의 흐름을 보시면 Player 클래스의 move 메서드가 CQS를 위반하는 move 메서드를 제공한다는 것을 알 수 있습니다.move는 이동 여부를 반환하는 쿼리인 동시에 position을 변경하는 명령이었죠.public class Player { public boolean move(Direction direction) { if (worldMap.isBlocked(position.shift(direction))) { return false; } this.position = this.position.shift(direction); return true; } 이 메서드를 CQS를 만족하도록 만들려면 move 메서드의 반환 타입은 void로 변경해야 합니다.하지만 이렇게 하면 클라이언트인 Game 클래스가 성공 여부를 알 수 없겠죠.여기에서 잠깐 객체지향에서 예외(exception)의 올바른 사용 방법을 살펴볼 필요가 있는데 예외는 정상적인 플로우가 아니라 발생할 수 없는 예외적인 케이스에만 사용하는게 좋습니다.(실무에서는 실용적인 이유때문에 플로우 변경을 위해 예외를 사용하는 경우가 많지만 엄격하게 보면 올바른 예외 사용 방법은 아니라고 생각하시는게 좋아요).여기에서 캐릭터가 지도를 이동하던 도중에 벽이 막혀서 이동할 수 없다는 플로우는 예외적인 케이스가 아니라 정상적인 플로우에 속합니다.따라서 예외를 던지는게 아니라 canMove 같은 쿼리를 이용해서 클라이언트가 이동 가능한 지 체크해서 막히자 않은 경우에만 이동한다는 플로우를 명시적으로 표시해 주는게 좋겠죠.public class Game { private void tryMove(Direction direction) { if (player.canMove(direction)) { // 이동 가능한 경우에는 player.move(direction); // 지도 이동 showRoom(); return; } showBlocked(); // 막혀있다면 이동 불가능 } } 그러면 Player의 move 메서드에서 다시 canMove 메서드를 호출한 뒤에 예외를 던지는 이유가 궁금하실텐데요이 상황에서 예외가 발생한다는 것은 클라이언트가 반드시 canMove 메서드를 호출해서 이동 가능한지 여부를 확인한 후에 move 메서드를 호출해야 한다는 계약을 어긴 경우이기 때문입니다.따라서 정상적인 상황이 아니라 인터페이스를 잘못 사용한 예외적인 경우이기 때문에 예외를 던지는게 정당화됩니다. 2. TDA 위반 여부1번에 대한 답변에서 if문을 사용한 이유는 이해가 되셨을것 같고 대신 TDA에 대해 좀 더 자세히 설명드릴게요.Player의 move 메서드를 보시면 canMove 메서드를 호출해서 이동 가능한지 스스로 체크하고 이동 불가능한 경우 예외를 던지기 때문에 자신의 상태를 스스로 관리하고 있습니다.따라서 Player의 move 메서드는 스스로 판단하고 스스로 자신의 위치를 변경하기 때문에 자율적인 객체이며 TDA 위반이 아닙니다.TDA를 위반하는 코드는 아래 처럼 필요한 상태를 물어본 후에 반환 값을 이용해서 직접 객체의 상태를 변경하는 구조를 가집니다.public class Game { private void tryMove(Direction direction) { Position position = player.position(); WorldMap worldMap = player.worldMap(); if (!worldMap.isBlocked(position.shift(direction))) { player.setPosition(position.shift(direction)); showRoom(); return; } showBlocked(); } } 위 코드를 보시면 Player에게 내부에 포함하고 있는 position과 worldMap에 대해 물어본 후에 이들을 기반으로 결정하고 position을 직접 수정하고 있습니다.반면에 강의의 코드는 내부 상태가 아니라 이동 가능한지 여부를 물어보고(이 판단은 Player가 직접 합니다) 어떤 위치로 가야하는지도 Player가 직접 결정합니다.move 메서드 안의 if문을 제거하더라도 클라이언트가 내부의 상태를 물어본 후에 직접 가능 여부를 판단하지 않고 Player가 스스로 판단하기때문에 TDA 위반이 아닙니다.따라서 앞에서 설명드린 것처럼 if문은 Player 와 협력하는 다른 클라이언트가 canMove 호출 없이 move를 호출하는 인터페이스 사용 규칙을 위반한 경우를 대비해서 넣은게 맞습니다. 답변이 되었는지 모르겠네요. 😊
- 2
- 2
- 379
Q&A
9-1 사소한 강의자료 오류
강명덕님 안녕하세요.확인해 보니 X의 위치가 target이 아닌 source로 이동해야 하는군요.동영상 자료 수정에 조금 시간이 걸려서 최대한 빠르게 수정 후에 말씀드릴게요. 🙂자세히 봐주시고 제보해 주셔서 감사합니다!
- 1
- 2
- 42
Q&A
7-3 상속을 이용한 중복 제거 질문 있습니다!
강명덕님 좋은 질문 남겨 주셔서 감사합니다!두 질문에 대해 차례대로 답변드릴게요. 🙂 1. 협력을 위해 제공하는 메시지를 확인하기 용이하기 때문인가요?먼저 첫 번째 질문인 “추상 클래스로만 추상화를 한 뒤, 상위 수준 객체가 인터페이스 대신 추상 클래스를 의존하면 안될까?”에 대해 답변 드릴게요.강의에서 설명드렸던 것처럼 JsonReader와 CsvReader에서 추상화를 추출하는 이유는 클라이언트인 CallCollector 입장에서 두 객체가 교체 가능해야 하기 때문입니다.이를 위해서는 CallCollector가 구체적인 JsonReader나 CsvReader에 의존하는 대신 두 타입의 객체를 포괄하는 상위 타입에만 의존하도록 의존성을 조정해야 합니다.여기에서 고민은 말씀하신 것처럼 Reader를 인터페이스 대신 추상 클래스로 만들 것인지, 아니면 인터페이스로 만들고 중복 코드는 추상 클래스인 AbstractReader에 남기는 안을 선택할 지를 결정해여 한다는 것입니다. 강의와 다르게 Reader를 추상 클래스로 선언하면 말씀하신 것처럼 read만 public이기 때문에public abstract class Reader { private String path; public Reader(String path) { this.path = path; } public List read() { List lines = readLines(path); return parse(lines); } private List readLines(String path) { try { return Files.readAllLines( Path.of(ClassLoader.getSystemResource(path).toURI())); } catch (Exception e) { throw new RuntimeException(e); } } protected abstract List parse(List lines); } CallCollector는 추상 클래스인 Reader를 의존성 주입받아서 사용하면 되겠죠.public class CallCollector { private final Reader reader; public CallCollector(Reader reader) { this.reader = reader; } public CallHistory collect(String phone) { List calls = reader.read(); ... } } 이렇게 구현을 해도 CsvReader와 JsonReader를 교체하겠다는 목표는 달성할 수 있습니다.여기에서 7-3을 포함하는 섹션7의 타이틀을 “외부 의존성과 테스트”라고 지었다는 점에 주목해 주시면 좋겠습니다.지금 우리는 CallCollector를 좀 더 쉽게 테스트하고 싶다는 점도 목표로 하고 있습니다.테스트 관점에서 현재의 Reader에서 신경 쓰이는 부분은 Reader가 외부의 파일 시스템에 의존하고 있다는 점입니다.Reader의 내부 구현은 런타임에 파일 시스템이 존재한다는 것을 가정하고 있습니다.단위 테스트는 외부의 파일 시스템에 의존하지 않고 메모리 안에 생성된 객체들을 이용해서 실행하는 것이 이상적이기 때문에 파일에 대한 의존성을 제거하고 테스트할 수 있다면 좋겠죠.Reader를 인터페이스로 만들면 파일에 대한 의존성을 제거할 수 있습니다.public interface Reader { List read(); } 파일에 대한 의존성은 AbstractReader로 이동시켰습니다.public abstract class AbstractReader implements Reader { private String path; public Reader(String path) { this.path = path; } public List read() { List lines = readLines(path); return parse(lines); } private List readLines(String path) { try { return Files.readAllLines( Path.of(ClassLoader.getSystemResource(path).toURI())); } catch (Exception e) { throw new RuntimeException(e); } } protected abstract List parse(List lines); } 이제 CallCollector는 파일에 대한 의존성을 가지지 않는 Reader 인터페이스에만 의존하기 때문에 쉽게 외부 의존성을 고려하지 않고도 쉽게 테스트할 수 있습니다.실제로 7-3의 11:07 부분을 보시면 가짜 객체(Fake Object) 방식으로 구현된 테스트 더블인 FakeReader를 이용해서 단위 테스트를 쉽게 실행할 수 있는 방법을 보여주고 있습니다.public class FakeReader implements Reader { private List calls; public FakeReader(Call ... calls) { this.calls = List.of(calls); } @Override public List read() { return calls; } } 물론 Reader가 추상 클래스인 경우에도 가짜 객체를 추가할 수는 있습니다.하지만 추상 클래스의 경우에는 Reader의 내부 구현을 이해한 상태에서 가짜 객체를 구현해야 하기 때문에 복잡해집니다.다음은 Reader가 추상 클래스인 경우의 FakeReader 구현입니다.public class FakeReader extends Reader { private List calls; public FakeReader(Call ... calls) { super(""); this.calls = List.of(calls); } @Override public List read() { return calls; } @Override protected List parse(List lines) { return List.of(); } } 상속은 내부 구현에 대한 결합도가 높기 때문에 내부 구현을 이해해야 한다는 점이 항상 문제를 일으킵니다.FakeReader를 구현하기 위해서는 Reader의 내부 구현을 이해해야 합니다.read 메서드만 오버라이딩하면 파일에 대한 의존성을 제거할 수 있다는 사실을 이해해야 합니다.FakeReader의 생성자에서 super 콜을 이용해서 Reader의 생성자를 호출해야 한다는 사실을 알고 있어야 합니다.Reader의 내부 구현에 속하지만 테스트와는 상관이 없는 parse 메서드도 오버로딩해야 합니다.이 경우에는 테스트를 위해 내부 구현에 대한 결합을 완전히 끊어내기 위해 인터페이스를 사용하는게 더 효과적입니다. 말씀하신 것처럼 인터페이스가 추상 클래스보다 역할을 한 눈에 파악하기 쉬운건 맞지만 설계 원칙을 기반으로 추상 클래스를 작게 만들 경우에는 이 부분에서 생각보다 차이가 크지는 않은 것 같아요.인터페이스를 하나 추가할 때의 비용(머리 속에서 트래킹해야 하는 요소가 하나 추사된 거니까요) 대비 이익이 크지 않다면 추상 클래스로 시작하시는게 좋습니다.이런 이슈가 발생하지 않고 단위 테스트 관점에서 큰 차이가 없는 경우라면 저도 강명덕님의 생각과 동일하게 추상 클래스로 시작했을 겁니다.나중에 여러 클래스 계층에 적용할 필요가 있는 경우에 인터페이스를 추출하는건 생각보다 쉽기 때문이죠. 2. 두 public 메서드 중 하나는 중복 로직, 하나는 각 구현체마다 다르게 구현하는 경우에도 인터페이스와 추상클래스를 함께 사용하는 것이 좋을까요?모든 자식 클래스들이 단일 상속 계층 안에 위치하는지 여부와 단위 테스트에 대한 필요성에 따라 판단하시면 될것 같습니다.만약 자식 클래스들이 여러 상속 계층을 구성한다면 인터페이스로 만드시는게 좋습니다.동일한 상속 계층에만 속한다면 인터페이스와 추상 클래스 둘 중 하나를 선택할 수 있습니다.CaptchaHashProcessor와 협력하는 다른 클래스를 단위 테스트해야 한다면 verify 메서드의 내부 구현이 통제하기 어려운 외부 요소에 의존하고 있는지 살펴보시는게 좋습니다.만약 verify 메서드가 불안정한 외부 요소를 사용한다면 인터페이스로 만드시는게 모킹이나 가짜 객체를 만들기에 용이합니다.만약 verify 메서드가 통제 가능한 요소들만 사용한다면 추상 클래스로 만드셔도 무방합니다. 이렇게 모든 클래스가 단일 상속 계층에 속하고 불안정한 요소게 의존하지 않기 때문에 단위 테스트에 문제를 일으키지 않는다면 말씀하신 것처럼 일단 추상 클래스에 의존하도록 만드셔도 무방합니다.대신에 추상 클래스의 이름은 Abstract를 제외한 CaptchaHashProcessor로 지으시는게 좋습니다.이렇게 하면 나중에 CaptchaHashProcessor를 인터페이스로 변경하더라도 클라이언트에 주는 영향을 최소화할 수 있습니다. 중복을 제거하고 코드를 더 쉽게 재사용할 수 있는 더 좋은 방법으로는 합성(Composition)이 있으며 색션 8에서 관련된 내용을 다루고 있습니다. 질문의 내용 구성에 대해 의견을 요청하셨는데 궁금하신 내용을 명확하게 정리해 주셔서 답변을 드리기가 쉬웠습니다.질문도 흥미로운 부분이라 강의에서 해당 부분을 좀더 자세히 설명했으면 좋았었겠구나라는 생각이 들었습니다.좋은 질문해 주셔서 감사드리고 답변 중에 미진한 부분이나 추가로 궁금한 부분 있으면 질문 남겨주시면 감사하겠습니다!
- 3
- 1
- 1K
Q&A
함수형 스타일에 대해선 어떻게 생각하시나요?
빡소리님 안녕하세요. 저같은 경우에는 객체지향쪽에 치우쳐 있기 때문에 객체지향 패러다임을 중심으로 함수형 패러다임을 접목시키는 방식으로 사용하고 있습니다. 전체 구조를 메시지를 주고 받는 객체사이의 협력을 기반으로 구현한다는 점은 동일합니다.차이점은 객체의 상태 변경 대신 새로운 객체를 반환하거나 도메인 이벤트를 발행시키는 등 함수형을 제한적으로 사용하고 있습니다.객체 인터페이스에 나타나는 람다는 strategy 패턴을 대체하는 정도의 용도로만 제한적으로 사용하고 있습니다. 코틀린 진영을 보면 불변 data 클래스를 사용하고 로직을 함수로 분리하는 방식으로 좀 더 적극적으로 함수형 프로그래밍을 적용하시는 케이스에 대한 이야기를 많이 듣고 있는데 이 부분에 대해서는 저도 경험이 많지 않아서 깊이 있는 내용을 말씀드리기 어렵네요. 🙂 설명이 부족해서 죄송합니다.
- 1
- 2
- 71