i - 들어가며
irc 서버를 C++로 구현하면서 부족한 점을 많이 느꼈다. C++를 사용하는 이유 중 하나가 객체지향 설계를 좀 더 용이하게 할 수 있는 것인데 프로그램을 만들면서 제대로 객체지향 개념을 이용하지 않았다.
그러다보니 디버깅하거나 기능을 추가할 때 어려움을 많이 겪었다. 하나의 클래스의 cpp 파일에 대부분의 로직이 담긴 코드가 들어가 있어서 고려해야할 코드의 범위가 넓었고 소요되는 시간도 오래 걸렸다.
객체지향에 대해서 키워드만 들어보고 무엇인지 잘 몰랐기 때문에 배워보고 객체지향적으로 설계해서 다시 리팩토링 해보자고 다짐했다.
객체지향에 대해서 책 ‘객체지향의 사실과 오해'를 추천으로 많이 받았기 때문에 책에서 배운 내용을 가지고 나름대로 설계를 시도해서 리팩토링 해보았다.
책 ‘객체지향의 사실과 오해’
1 - 책을 읽고 배운 내용
1.1 - 공동의 목표를 위해 협력하는 자율적인 객체들의 공동체를 만들어라.
우리가 대부분 프로그램을 만드는 이유는 사용자가 만족하는 기능을 구현하기 위해서다. 객체지향 설계는 사용자가 만족하는 기능이라는 목표를 달성하기 위해 협력하는 객체들의 세상을 만드는 것이다. 사람들의 경우 공동의 목표를 달성하기 위해 협력을 한다. 객체들도 공동의 목표인 애플리케이션 기능을 구현하기 위해서 서로 협력한다.
애플리케이션의 기능은 더 작은 책임으로 분할된다. 책임은 적절한 역할을 수행하는 객체가 맡는다. 객체는 자신이 맡은 책임을 수행하는 도중에 다른 객체에게 도움을 요청하기도 한다. 시스템의 기능은 그 객체 간의 요청과 응답이 연쇄적으로 이어지는 협력으로 구현된다.
이러한 공동체를 이루기 위해 객체는 충분히 협력적이면서 자율적이어야 한다. 협력적이고 자율적인 객체들로 이루어진 공동체를 창조하는 것이 유지보수성이 좋은 객체지향 프로그램을 만드는 것이다.
1.2 - 협력에 필요한 책임을 적절한 역할을 수행하는 객체에게 할당해라.
협력적인 객체는 다른 객체의 요청을 충분히 들어주고 자신도 다른 객체에게 적극적으로 요청한다. 역할이라는 구조를 기반으로 요청을 하고 책임을 배치해야 변경에 안정적인 프로그램으로 만들 수 있다.
보통 역할 구조를 만들기 위해서 도메인 모델이라는 것을 사용한다. 범용적이고 이해하기 쉽기 때문이다. 깔끔하고 단순하며 유지보수하기 쉬워진다. 협력적이지 않고 모든 것을 스스로 처리하려고 하는 객체는 자신 내부의 복잡도에 의해서 자멸하고 만다.
사용자의 요구사항은 계속해서 변화한다. 지속적인 변화에 안정적으로 대응하기 위해서는 기능이 아니라 구조에 기반해야한다. 구조를 기반으로 기능을 추가하면 사용자가 원하는 기능을 빠르고 안정적으로 추가할 수 있다. 확장가능하고 유지보수하기 쉬운 프로그램을 만들 수 있다.
1.3 - 자율적인 객체를 만들기 위해서는 행동(프로세스)이 상태(데이터)를 결정하도록 해라.
자율적인 객체는 요청에 응답할 때 스스로 판단해서 결정하고 처리한다. 자율적인 객체라면 캡슐화가 보장된다. 내부를 수정할 때는 그 객체만 신경쓰면 된다. 빠르고 안정적으로 변화할 수 있다.
자율적인 객체가 되기 위해서는 행동을 할 때 필요한 상태인 데이터를 스스로 가지고 관리해야한다. 다른 객체 입장에서는 데이터를 어떻게 사용해서 응답하는지 알 수 없어야 한다.
자율적인 객체를 만들기 위한 기본적인 방법은 행동을 구현하면서 필요한 데이터를 그 때 결정해서 포함하는 것이다. 그러면 객체의 구현 세부 사항을 외부에 노출시키지 않을 수 있고 인터페이스와 구현을 분리할 수 있다.
객체 공동체에 속하는 객체들은 공동의 목표 달성을 위해 협력에 참여하지만 스스로의 결정과 판단에 따라 행동하는 자율적인 존재다.
2 - 기존의 irc 서버 코드의 문제점
기존 서버의 객체들의 종류는 Server, Client, Channel 이렇게 3가지였다. 책을 읽고 파악한 문제들은 아래와 같았다.
2.1 - 하나의 객체가 하는 일이 너무 많았다.
Server 객체가 혼자서 많은 일을 담당했다. 네트워크 연결과 통신을 담당하고 받은 프로토콜 메시지에 대한 명령어 로직을 진행하고 보낼 프로토콜 메시지를 만들어서 응답을 보내는 부분까지 거의 모든 것을 담당하고 있었다.
Server class 안에서 네트워크, 메시지와 irc 프로토콜 명령어까지 모두 처리한다. …나중에 호되게 당했다.
다른 객체인 Client와 Channel 객체들이 있었지만 각자 데이터만 가지고 있고 getter, setter만 있는 구조체 수준이었다. 절차지향과 다를게 없었다.
이렇게 된 이유는 처음에 따로 설계를 하지 않고 Server class에다가 네트워크 처리하는 부분 부터 우선 구현했다가 이후 기능들도 그 위에 추가하는 방식으로 진행했기 때문이다.
결과적으로 Server 객체가 협력적이지 않고 모든 것을 혼자 처리하는 객체가 되어버렸다. 혼자서 너무 많은 일을 처리해서 내부적인 복잡도가 높았다. 디버깅이나 기능을 추가할 때 Server class의 어느 부분을 수정해야하는지 파악해야할 영역이 너무 많았고 시간도 오래 걸렸다. 또 코드를 수정했더라도 어느 영역까지 영향을 미칠지 파악하기도 어려웠다.
2.2 - 객체의 캡슐화가 깨져있었다.
Server 객체가 Client와 Channel 객체의 내부 데이터를 직접 다루고 있었다.
이런 상황이라면 만약 Client 객체가 가진 데이터에 문제가 생겼을 때 Server 객체가 그 데이터를 조작하는 부분을 직접 찾아야한다.
그러면 의존성이 높아져서 특정 class를 수정해야할 때 마다 다른 class의 코드까지 매번 확인해야해야 한다. 유지보수성이 떨어지는 것이다.
Server 객체가 Client 객체의 데이터를 getRecvBuf().append()를 통해 직접 조작하고 있다. 물론 다른 문제들도 많다…
예를들어 위 사진과 같이 된 이유는 처음에 Client class를 만들면서 데이터부터 결정했기 때문이다. 클라이언트 객체라면 실제 클라이언트에게 받은 메시지를 가지고 있어야 한다는 생각으로 먼저 결정했다. 그러고 Server 객체가 네트워크 소켓으로 클라이언트의 메시지를 받을 때 클라이언트의 recv_buf를 사용하자는 생각으로 직접 데이터를 조작했다.
3 - irc 서버 리팩토링 하기
3.1 - 구조를 만들고 협력 구성하기
총 5가지 객체로 이용해서 역할을 나눴다. Server, Client, Channel, Command, Protocol 객체들이다. 하나씩 역할을 설명하면 아래와 같다.
Server 객체가 네트워크 관련 부분을 처리한다. 네트워크 소켓을 이용해서 통신을 연결하고 끊거나 프로토콜 메시지를 주고 받는 등의 역할을 담당한다.
Server class의 멤버함수들
Client 객체는 실제 클라이언트와 대응해서 관련된 데이터를 관리하는 역할을 맡았다. 받은 메시지를 파싱하고 보낼 메시지를 저장하는 등의 책임 등을 담당한다.
Client class의 멤버함수들
Channel 객체는 클라이언트 프로그램의 채널에 대응되는 객체다. 채널과 관련된 책임들을 수행한다.
Channel class의 멤버함수들
Command 객체는 irc 프로토콜 메시지의 명령어 로직을 처리하는 역할을 담당한다.
Command class의 멤버함수들
Protocol 객체는 클라이언트에게 보낼 irc 프로토콜 메시지를 만드는 역할을 담당한다.
Protocol class의 멤버함수들
3.2 - 인터페이스 정의하고 할당하기
다른 객체에게 요청을 해야할 때 필요한 인터페이스를 정의하고 그 기능을 수행하기에 적합한 객체를 선택해서 그 객체의 class의 멤버 함수로 할당해줬다.
예를 들어 Command 객체가 PART 명령어 로직을 처리한다고 보자. PART 명령어는 클라이언트가 특정 채널을 나간다고 요청하는 명령어다. 이 부분을 구현하면서 그 채널 안에 명령어를 요청한 클라이언트가 등록이 되어있는지 확인하는 부분이 필요하다. 기존에 그런 요청을 받는 객체가 없다면 필요한 인터페이스를 정의하고 가장 적합한 객체를 선택해서 그 객체의 인터페이스로 등록한다. 여기서는 isUserIn() 이라는 인터페이스를 정의하고 Channel class에 멤버함수로 등록해서 사용했다.
Command.part()에서 Channel.isUserIn()가 있는 부분
다른 객체에게 요청할 때는 너무 구체적으로 행동을 정의해서 요청하지 않으려고 했다. 적당히 추상화 시켜서 그 객체의 자율성을 보장해주고자 했다. 예를 들면 채널에 이미 클라이언트가 등록이 되어있는지 확인할 때, 그 클라이언트를 어떤 방식으로 찾는지는 요청을 하지 않는 것이다. 그 부분은 Channel 객체가 알아서 하도록 맡기는 것이다. ‘무엇'에 대해서만 요청하고 ‘어떻게'에 대해서는 요청하지 않도록 했다.
3.3 - 멤버 함수 구현하기
클래스에 할당한 멤버함수를 실제로 구현할 때 특정 데이터가 필요할 때가 있다. 그 데이터가 그 클래스 안에 정의가 되어있지 않다면 그 때 데이터를 어떻게 저장할지 결정하고 추가했다. 행동이 상태를 결정하도록 진행했다. 그래야 수월하게 캡슐화를 보장할 수 있고 자율적인 객체로 만들 수 있기 때문이다.
Channel class의 데이터들은 멤버함수를 구현하는 도중에 필요하면 결정하고 추가하였다.
구현하면서 다른 객체의 데이터를 직접 다루지 않도록 했다. 다른 객체에게 요청을 통해서 값을 확인하거나 수정을 해야한다. 이때 위에서 언급했던 것 처럼 인터페이스를 정의하고 적합한 객체에게 책임을 할당하고 그 멤버함수를 구현하는 방식으로 진행했다. 인터페이스를 정의하고 적절한 객체에게 할당해서 이에 대한 멤버 함수 구현하는 순환, 반복 프로세스가 이어지면서 기능을 구현했다.
필요한 인터페이스 정의, 적절한 객체에게 할당, 멤버함수 구현의 순한, 반복 프로세스
위와 같은 방식으로 구현해서 이전에 Server 객체가 Client 객체의 데이터를 직접 조작하는 부분을 제거할 수 있었다. 캡슐화를 통해 Client의 인터페이스로 요청만 받도록 만들었다.
리팩토링 이후에 기존에 문제가 되었던 부분에서 Server 객체가 Client 객체의 데이터를 직접 조작하지 않고 요청을 보내도록 했다.
o - 마무리
객체지향 설계를 시도하며 구현해봤다는 점에서 의미가 있었다. 객체지향의 장점을 느낄 수 있었다. 구조를 기반으로 동작을 구현하니까 빠르게 기능을 추가할 수 있었다. 새로운 기능을 위한 협력을 구상하고 그 협력에 필요한 책임을 적절한 역할을 가진 객체에게 할당해서 구현해주면 되었다. 오류가 났을 때 오류가 발생한 영역을 빠르게 찾고 그 오류를 가진 객체만 수정해주면 돼서 디버깅도 쉽게 할 수 있었다. 무엇보다 소프트웨어로 객체들이 서로 협력하는 세상을 만든다고 생각하니까 너무 재밌었다.
글을 쓰면서 책을 다시 읽었는데 리팩토링한 구조를 다시 돌아보니 아쉬움도 많이 남는다. 객체지향에서 중요한 상속, 다형성 개념을 활용하지 않았다. 지금은 프로토콜 명령어 로직 부분을 Command 객체만 이용해서 멤버함수로 처리했다. Command 객체의 상속을 통해 각 프로토콜 명령어에 대응하는 객체를 만들어서 활용했으면 더 객체지향적으로 만들 수 있을 것 같다. 명령어 객체들은 그 명령어의 로직을 처리한다는 책임을 가지고 같은 역할을 담당하기 때문이다. 코드를 최적화 하면서 이 부분을 상속을 이용해서 더 개선해야겠다.
객체지향을 사용하는 이유는 안정적으로 빠르게 기능을 변경하기 위해서다. 사용자들의 요구사항은 언제나 변하기 마련이다. 깔끔해서 유지보수하기 쉬운 설계라면 사용자의 요구를 금방 반영할 수 있을 것이다. 물론 객체지향이 무조건 좋은 것은 아니다. 게임 서버와 같이 매우 빠른 속도가 중요한 경우라면 객체지향을 사용하지 않는 경우도 있다. 객체 포인터를 통해 한 번 더 들어가서 접근해야하기 때문이다. 그럼에도 현재 많은 프로그램들을 객체지향 설계로 구현한 이유는 사용자의 변화하는 요구에 빠르게 대응하는 것이 그만큼 중요하다는 의미일 것이다.
※ 참고서적
•
객체지향의 사실과 오해 [책]