디미터 법칙과 함수 체이닝

53728 단어 cleancodeCsharpCsharp

1. 함수 체이닝

함수 체이닝은 선행 함수의 결과에 대해 함수를 호출해야 할 때 함수를 연결하여 호출하는 테크닉입니다.

using System;
					
public class Program
{
	public class Name
	{
		public string FirstName { private set; get; }
		public string LastName { private set; get; }
		
		public Name(string inFirstName, string inLastName)
		{
			FirstName = inFirstName;
			LastName = inLastName;
		}
		
		public Name ChangeFirstName(string inFirstName)
		{
			FirstName = inFirstName;
			return this;
		}
		
		public Name ChangeLastName(string inLastName)
		{
			LastName = inLastName;
			return this;
		}
		
		public new string ToString()
		{
			return $"{FirstName} {LastName}";
		}
	}
	
	public static void Main()
	{
		Name myName = new Name("David", "Cho");
		Console.WriteLine(myName.ToString());
		
		myName.ChangeFirstName("Paul").ChangeLastName("Kim");
		Console.WriteLine(myName.ToString());
	}
}

위 코드에서 39번째 라인을 보면 myName 이라는 객체에 대해 ChangeFirstName, ChangeLastName 이라는 함수를 연결해서 호출하고 있습니다.
각 함수는 자신이 속한 객체 인스턴스를 반환하여 그 결과에 대해 또 함수를 호출할 수 있도록 합니다.
이렇게 함수를 연결하여 호출하는 방식을 함수 체이닝이라고 합니다.

위 예제에서는 하나의 객체에 대해 함수 체이닝을 사용해서 객체의 상태 (내부 데이터)를 변경하고 있습니다. 또 하나 C#에서는 더 자주, 유용하게 사용되는 함수 체이닝이 있습니다. 바로 LINQ 입니다.

using System;
using System.Linq;
					
public class Program
{	
	public static void Main()
	{
		int[] data = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
		
		var filteredArray = data.Where(v => v > 5).OrderByDescending(v => v).ToArray();
		
		Console.WriteLine(filteredArray.Select(v => v.ToString()).Aggregate((a, b) => $"{a}, {b}"));
		Console.WriteLine(data.Select(v => v.ToString()).Aggregate((a, b) => $"{a}, {b}"));
	}
}

10번째 라인에서 LINQ 함수를 체이닝해서 data 배열을 변경합니다.
Name 객체에 대한 함수 체이닝 예제에서는 각 함수가 반환하는 객체가 모두 동일합니다. 바로 자신이 속한 객체 인스턴스를 반환하고 있죠.
하지만 LINQ 예제에서는 체이닝 된 각 함수의 반환 인스턴스가 제각각 다릅니다.

Where 함수의 경우 동일한 IEnumerable 타입으로 Wrapping 되어 있을 뿐 데이터가 다른 인스턴스가 반환됩니다. 심지어 ToArray 함수의 경우 반환 타입 자체가 달라집니다.
filteredArraydata 를 출력해보면 서로 다른 결과가 나오는 걸 확인할 수 있습니다.

이렇게 함수 체이닝을 사용할 경우 체이닝 된 함수를 따라 데이터의 흐름을 읽을 수 있다는 장점이 있습니다.
가독성이 좋아지는 거죠.

객체 대상으로 사용할 때는 부수효과와 함수의 호출 시점 제한에 주의해야 합니다.
LINQ와 같이 체이닝 된 함수가 원래 데이터를 변경하지 않는 경우에는 크게 신경 쓸 필요는 없지만요.


2. 디미터 법칙과 함수 체이닝

클린 코드에서는 디미터 법칙을 아래와 같이 소개하고 있습니다.

디미터 법칙은 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 법칙이다.
좀 더 정확히 표현하자면, 클래스 C의 메서드 f는 다음과 같은 객체의 메서드만 호출해야 한다.

  1. 클래스 C
  2. f가 생성한 객체
  3. f 인수로 넘어온 객체
  4. C 인스턴스 변수에 저장된 객체

이 법칙을 충실하게 지키면 다른 객체의 내부 데이터나 구현 내용을 미리 알아야 할 필요가 없어지므로 결합도가 낮아지고 유연성이 높아져 수정이 쉬워집니다.
결합도가 낮아지면 그만큼 클래스의 책임 영역이 명확해지기 때문에 다른 환경에서 재사용하기도 편해지죠.

그렇다면 디미터 법칙과 함수 체이닝이 무슨 관련이 있을까요?

일반적인 경우엔 디미터 법칙을 최대한 준수하면서 코딩을 하다가도 함수 체이닝을 사용하다 보면 자신도 모르게 디미터 법칙을 어기게 되는 경우가 있습니다.

바로 아래와 같은 경우입니다.

using System;
using System.Collections.Generic;
using System.Linq;
					
public class Program
{
	public class Library
	{
		private List<User> _members;
		private List<Book> _books;
		
		public Library()
		{
			_members = new List<User>();
			_books = new List<Book>();
		}
		
		public User GetMemberById(int inId)
		{
			if (_members.Any(v => v.Id == inId))
			{
				return _members.FirstOrDefault(v => v.Id == inId);
			}
			return User.None;
		}
	}
	
	public class User
	{
		public static readonly User None = new User(0, string.Empty);
		public int Id { private set; get; }
		public string Name { private set; get; }
		private Book _rentBook;
		
		public User(int inId, string inName)
		{
			Id = inId;
			Name = inName;
		}
			
		public Book GetRentBook()
		{
			return _rentBook ?? Book.None;
		}
	}
		
	public class Book
	{
		public static readonly Book None = new Book(string.Empty, string.Empty);
		public string Name { private set; get; }
		public string Author { private set; get; }
		
		public Book(string inName, string inAuthor)
		{
			Name = inName;
			Author = inAuthor;
		}
		
		public string GetBookInfo()
		{
			if (this.Equals(Book.None)) return string.Empty;
			return $"{Name} by {Author}";
		}
		
		public new bool Equals(object inBook)
		{
			if (inBook == null) return false;
			if (Object.ReferenceEquals(this, inBook)) return true;
			
			if (inBook.GetType() != typeof(Book)) return false;
			
			Book target = inBook as Book;
			if (!Name.Equals(target.Name) || !Author.Equals(target.Author)) return false;
			return true;
		}
	}
	
	public static void Main()
	{
		Library myLibrary = new Library();	
		Console.WriteLine(myLibrary.GetMemberById(1).GetRentBook().GetBookInfo());
	}
}

위 코드는 도서관의 1번 회원이 빌려간 책의 정보를 조회하는 프로그램입니다.
해당 모듈에서는 Library 객체를 갖고 있습니다.
그리고 81라인에서 myLibrary 객체에서 1번 회원을 조회한 후 해당 회원이 빌려간 책을 조회합니다. 그리고 해당 책의 정보를 반환하여 화면에 출력합니다.

언뜻 보기엔 함수 체이닝을 통해 매끄럽게 로직을 읽을 수 있지만 이 코드는 디미터 법칙을 위반했습니다.

현재 모듈에서 알고 있는 객체 정보는 Library 가 유일합니다.
그러므로 디미터 법칙에 의하면 이 모듈에서는 Library의 함수만 호출해야 합니다.

먼저 GetMemberById 함수는 Library 클래스의 함수이므로 디미터 법칙을 위반하지 않습니다.
하지만 GetRentBook 함수는 User 클래스의 함수이므로 디미터 법칙을 위반합니다.
또한 GetBookInfo 함수는 Book 클래스의 함수이므로 디미터 법칙을 위반합니다.

여기서 User 객체, Book 객체는 디미터 법칙 2번, f 가 생성한 객체에 해당하지 않냐고 생각할 수도 있습니다.
하지만 여기서 고래해야 할 점은 위 함수로 받아온 객체가 "생성된" 객체가 아니라 "참조된" 객체라는 것입니다.

f에서 "생성된" 객체는 f에서만 사용되며 f 가 종료되면 사라집니다.
하지만 "참조된" 객체는 다른 메모리에 상주하고 있으며 변경이 발생할 경우 다른 객체의 상태를 변경하여 부수 효과를 일으킬 수 있습니다.

그나마 위 겨우 쥐어짜 낸 위 예제는 Getter로 참조만 하고 있는 로직이므로 부수효과가 발생하지 않겠지만 만약 상태를 변경하는 함수를 체이닝을 통해 호출한다면 어떤 객체에 대해 무슨 동작을 했는지 쉽게 알아차리기 어려워집니다.

그래서 클린 코드에서는 이런 경우에 대해 2가지 해법을 제시합니다.

1. 문맥 상 하나의 역할을 하는 함수로 묶어라

using System;
using System.Collections.Generic;
using System.Linq;
					
public class Program
{
	public class Library
	{
		private List<User> _members;
		private List<Book> _books;
		
		public Library()
		{
			_members = new List<User>();
			_books = new List<Book>();
		}
		
		public User GetMemberById(int inId)
		{
			if (_members.Any(v => v.Id == inId))
			{
				return _members.FirstOrDefault(v => v.Id == inId);
			}
			return User.None;
		}
		
		public string GetUsersRentedBookInfo(int inId)
		{
			User member = GetMemberById(inId);
			return member.GetRentBookInfo();
		}
	}
	
	public class User
	{
		public static readonly User None = new User(0, string.Empty);
		public int Id { private set; get; }
		public string Name { private set; get; }
		private Book _rentBook;
		
		public User(int inId, string inName)
		{
			Id = inId;
			Name = inName;
		}
			
		public Book GetRentBook()
		{
			return _rentBook ?? Book.None;
		}
		
		public string GetRentBookInfo()
		{
			return GetRentBook().GetBookInfo();
		}
	}
		
	public class Book
	{
		public static readonly Book None = new Book(string.Empty, string.Empty);
		public string Name { private set; get; }
		public string Author { private set; get; }
		
		public Book(string inName, string inAuthor)
		{
			Name = inName;
			Author = inAuthor;
		}
		
		public string GetBookInfo()
		{
			if (this.Equals(Book.None)) return string.Empty;
			return $"{Name} by {Author}";
		}
		
		public new bool Equals(object inBook)
		{
			if (inBook == null) return false;
			if (Object.ReferenceEquals(this, inBook)) return true;
			
			if (inBook.GetType() != typeof(Book)) return false;
			
			Book target = inBook as Book;
			if (!Name.Equals(target.Name) || !Author.Equals(target.Author)) return false;
			return true;
		}
	}
	
	public static void Main()
	{
		Library myLibrary = new Library();	
		
		Console.WriteLine(myLibrary.GetMemberById(1).GetRentBook().GetBookInfo());
		Console.WriteLine(myLibrary.GetUsersRentedBookInfo(1));
	}
}

93 라인은 함수 체이닝을 통해 User 객체와 Book 객체를 외부로 노출하였습니다.

사람마다 생각이 다를 순 있지만, 문맥 상으로 1번 유저가 빌려간 책의 정보를 조회한다 라는 동작은 하나의 책임으로 봐도 될 것 같습니다. 그렇다면 이 동작을 하나의 함수로 묶어도 무방합니다.

그래서 94 라인에서 GetUserRentedBookInfo라는 함수를 생성하여 호출하고 있습니다.

디미터 법칙을 만족함으로 인해 더 이상 코드에서는 User 객체와 Book 객체의 API (메서드 시그니쳐)를 알아야 할 필요가 없습니다. 따라서 결합도도 낮아졌고요.

2. 디미터 법칙은 객체에 적용되는 법칙이다. 아예 자료 구조로 전환해 버리자

객체는 데이터를 숨기고 그 동작만 공개하여 안정성과 유연성을 확보하기 위한 노력의 일환입니다.
그에 반에 자료 구조는 의도적으로 그 데이터를 공개하여 데이터의 목적과 용도를 드러냅니다.

using System;
using System.Collections.Generic;
using System.Linq;
					
public class Program
{
	public class Library
	{
		private List<User> _members;
		private List<Book> _books;
		
		public Library()
		{
			_members = new List<User>();
			_books = new List<Book>();
		}
		
		public User GetMemberById(int inId)
		{
			if (_members.Any(v => v.Id == inId))
			{
				return _members.FirstOrDefault(v => v.Id == inId);
			}
			return User.None;
		}
	}
	
	public struct User
	{
		public static readonly User None = new User {
			Id = 0, 
			Name = string.Empty,
			RentBook = Book.None
		};
		
		public int Id = 0;
		public string Name = "";
		public Book RentBook = Book.None;
	}
		
	public struct Book
	{
		public static readonly Book None = new Book(string.Empty, string.Empty);
		public string Name = "";
		public string Author = "";
		
		public Book(string inName, string inAuthor)
		{
			Name = inName;
			Author = inAuthor;
		}
		
		public bool Equals(Book inBook)
		{
			return Name.Equals(inBook.Name) && Author.Equals(inBook.Author);
		}
	}
	
	public static void Main()
	{
		Library myLibrary = new Library();	
		Console.WriteLine($"{myLibrary.GetMemberById(1).RentBook.Name} by {myLibrary.GetMemberById(1).RentBook.Author}");
	}
}

클래스였던 UserBook은 구조체가 되었습니다.
공개된 자료 구조인 UserBook은 기본적으로 정보를 공개하도록 구현되었기 때문에 디미터 법칙을 적용하지 않아도 무방합니다.
또한 공개되는 데이터 역시 참조가 아니라 복사되는 것이므로 부수효과를 일으킬 위험도 없습니다.


3. 정리

함수 체이닝은 잘게 나누어진 부품 함수들을 조합하여 가독성을 높이고 로직의 흐름을 이해하기 쉽게 만드는 테크닉입니다.
그러나 함수 체이닝을 무분별하게 사용하다 보면 자신도 모르게 객체의 결합도를 높이는 결과를 초래하기도 합니다.

애초에 데이터를 공개하는 자료 구조를 대상으로 함수 체이닝을 할 때는 디미터 법칙을 고려할 필요가 없습니다. 또한 체이닝 된 함수들이 원시 타입 데이터를 반환할 때는 값들이 항시 복사되며 전파되기에 부수효과를 걱정할 필요도 없습니다.

그러나 객체를 대상으로 함수 체이닝을 사용할 때는 조심해야 합니다.
체이닝 된 함수들이 디미터 법칙을 위반하지는 않는지 각 함수들을 잘 살펴봐야 합니다.
그리고 만약 디미터 법칙을 위반한 함수가 있다면 해당 함수를 호출하는 책임을 원래 객체에게 돌려줄 수 있는지 확인해야 합니다.

겨우 함수 하나 두 개에 대해 법칙 씩이나 들이대며 따지는 게 너무 좀스러워 보일 수도 있습니다.
법칙을 지키기 위해 만든 함수명이 너무 길어 마음에 안들 수도 있죠.
하지만 이런저런 예외가 하나 둘 쌓여 거대해지면 더 손대기 어려운 문제가 발생할 수도 있습니다.

그러니 아주 조그만 개선이라도 지금 당장 바꿀 수 있다면 조금씩 바꿔가자는 마음가짐으로 개발하고 있습니다.
적어도 퇴근할 때는 출근할 때보다 더 깨끗한 코드를 남기고 가기 위해서요.

캠프장은 처음 왔을 때 보다 더 깨끗하게 해 놓고 떠나라.

좋은 웹페이지 즐겨찾기