로버트 마틴이 쓴 열고 닫기 원칙에서 Client-Server 예를 C#으로 이해하기
이철우
이 글은 로버트 마틴[참고 1]이 쓴 - The Open-Closed Principle[참고 2] - 안에 작은 제목 'Abstraction is the Key’에 있는 '닫힌 클라이언트’와 '열린 클라이언트’를 C# 예제로 풀어놓은 것이다. 열고 닫기 원칙에 대해서는 [참고 2]와 버트란드 메이어[참고 3]을 참고하기 바란다.
인터넷에서 시각을 읽어와 출력하는 것을 모델로 Client-Server를 구현하겠다. DotNet 8 Console 프로젝트를 만들고 읽기와 쓰기를 ‘한 메서드에 모두’ - Program.cs - 에 넣은 뒤 이것을 분리/추상화 하겠다.
모델 설정 - 한 메서드에 모두
// Program.cs
Console.WriteLine("Hello, World!");
await AllInOne().ConfigureAwait(false);
Console.WriteLine("Bye.");
return;
async Task AllInOne()
{
using var httpClient = new HttpClient();
var url = "https://timeapi.io/api/Time/current/zone?timeZone=Asia/Seoul";
var json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
dynamic? jsonObject = System.Text.Json.Nodes.JsonNode.Parse(json);
var text = jsonObject?["dateTime"].ToString();
var dateTime = DateTimeOffset.Parse(text);
Console.WriteLine($"{dateTime}");
}
실행하니 잘 작동한다.
닫힌 클라이언트 - Client 와 Server 분리
시각을 읽는 부분을 Server, 쓰는 부분을 ClosedClient로 분리하겠다.
[참고 2의 그림 1]닫힌 클라이언트
// Server.cs
public class Server
{
public async Task<DateTimeOffset> Request()
{
using var httpClient = new HttpClient();
var url = "https://timeapi.io/api/Time/current/zone?timeZone=Asia/Seoul";
var json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
dynamic? jsonObject = System.Text.Json.Nodes.JsonNode.Parse(json);
var text = jsonObject?["dateTime"].ToString();
var dateTime = DateTimeOffset.Parse(text);
return dateTime;
}
}
// ClosedClient.cs
public class ClosedClient
{
public async Task PrintDateTime(Server timeServer)
{
var dateTime = await timeServer.Request().ConfigureAwait(false);
Console.WriteLine($"{dateTime}");
}
}
// Program.cs
Console.WriteLine("Hello, World!");
var client = new ClosedClient();
await client.PrintDateTime(new Server()).ConfigureAwait(false);
Console.WriteLine("Bye.");
실행하니 잘 작동한다. 이렇게 하면, Server를 다른 것으로 바꾸어야 할 때, ClosedClient가 Server를 직접 사용하기 때문에 ClosedClient도 바꾸어야 한다. 열고 닫기 원칙을 깨게 된다.
열린 클라이언트 - Server 추상화
Server를 추상화하는 인터페이스 IServer를 만들어 Server에 물려주고 OpenClient는 IServer를 사용하자.
[참고 2의 그림 2]열린 클라이언트
// IServer.cs
public interface IServer
{
Task<DateTimeOffset> Request();
}
// Server.cs
public class Server : IServer
{
public async Task<DateTimeOffset> Request()
{
using var httpClient = new HttpClient();
var url = "https://timeapi.io/api/Time/current/zone?timeZone=Asia/Seoul";
var json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
dynamic? jsonObject = System.Text.Json.Nodes.JsonNode.Parse(json);
var text = jsonObject?["dateTime"].ToString();
var dateTime = DateTimeOffset.Parse(text);
return dateTime;
}
}
// OpenClient.cs
public class OpenClient
{
public async Task PrintDateTime(IServer timeServer)
{
var dateTime = await timeServer.Request().ConfigureAwait(false);
Console.WriteLine($"{dateTime}");
}
}
Console.WriteLine("Hello, World!");
var client = new OpenClient();
IServer server = new Server();
await client.PrintDateTime(server).ConfigureAwait(false);
Console.WriteLine("Bye.");
실행하니 잘 작동한다. 이제 Server를 하나 더 추가하자. IServer 물려받은 OtherServer를 만들자. 시각 서버 주소가 달라 응답 Json이 바뀌었으므로 파싱 부분을 고쳐야 한다. 그리고 Program.cs 에서 OtherServer를 사용하자.
// OtherServer.cs
public class OtherServer : IServer
{
public async Task<DateTimeOffset> Request()
{
using var httpClient = new HttpClient();
var url = "http://worldtimeapi.org/api/timezone/Asia/Seoul";
var json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
dynamic? jsonObject = System.Text.Json.Nodes.JsonNode.Parse(json);
var text = long.Parse(jsonObject?["unixtime"].ToString());
var dateTime = (DateTimeOffset)DateTimeOffset.FromUnixTimeSeconds(text);
return dateTime.ToLocalTime();
}
}
// Program.cs
Console.WriteLine("Hello, World!");
var client = new OpenClient();
IServer server = new Server();
await client.PrintDateTime(server).ConfigureAwait(false);
server = new OtherServer();
await client.PrintDateTime(server).ConfigureAwait(false);
Console.WriteLine("Bye.");
실행하니 잘 작동한다. 기존 코드 - OpenClient.cs - 를 바꾸지 않고(Closed) 추상화(IServer)를 통해 Server를 확장(Open)하는 예였다.
한 걸음 더 - 메서드 기능 나누고 추상화
Client 메서드 Request()에서 시각 서버 주소는 매개변수로 받고, 파싱 부분을 따로 나누어 추상화 - IParser.cs - 하고 이에 맞게 IServer 를 바꾸자.
// IParser.cs
public interface IParser
{
DateTimeOffset Convert(string input);
}
// IServer.cs
public interface IServer
{
Task<DateTimeOffset> Request(string url, IParser parser);
}
// Server.cs
public class Server : IServer
{
public async Task<DateTimeOffset> Request(string url, IParser parser)
{
using var httpClient = new HttpClient();
var json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return parser.Convert(json);
}
}
IParser를 물려벋은 ParserA, ParserB를 두 개의 시각 Json에 맞게 구현하자.
// ParserA.cs
public class ParserA : IParser
{
public DateTimeOffset Convert(string json)
{
dynamic? jsonObject = System.Text.Json.Nodes.JsonNode.Parse(json);
var text = jsonObject?["dateTime"].ToString();
var dateTime = DateTimeOffset.Parse(text);
return dateTime;
}
}
// ParserB.cs
public class ParserB : IParser
{
public DateTimeOffset Convert(string json)
{
dynamic? jsonObject = System.Text.Json.Nodes.JsonNode.Parse(json);
var text = long.Parse(jsonObject?["unixtime"].ToString());
var dateTime = (DateTimeOffset)DateTimeOffset.FromUnixTimeSeconds(text);
return dateTime.ToLocalTime();
}
}
이제 이것들을 사용할 OpenClient.cs, Program.cs를 바꾸자.
// OpenClient.cs
public class OpenClient
{
private readonly IServer _timeServer;
public OpenClient(IServer timeServer)
{
_timeServer = timeServer;
}
public async Task PrintDateTime(string url, IParser parser)
{
var dateTime = await _timeServer.Request(url, parser).ConfigureAwait(false);
Console.WriteLine($"{dateTime}");
}
}
// Program.cs
Console.WriteLine("Hello, World!");
IServer server = new Server();
var client = new OpenClient(server);
var urlA = "https://timeapi.io/api/Time/current/zone?timeZone=Asia/Seoul";
await client.PrintDateTime(urlA, new ParserA()).ConfigureAwait(false);
var urlB = "http://worldtimeapi.org/api/timezone/Asia/Seoul";
await client.PrintDateTime(urlB, new ParserB()).ConfigureAwait(false);
Console.WriteLine("Bye.");
실행하니 잘 작동한다. 파싱 관련 세 개의 파일이 생기고 Server 재활용으로 OtherServer가 없어졌다. 각 시각 서버의 Json 형태가 바뀌더라도 그 형태에 맞게 IParser 물려받은 Parser를 구현/적용하면 된다. OpenClient와 Server는 바꿀 필요가 없다. 또 다른 시각 서버가 추가되는 경우에도 그 서버 URL과 적절한 Parser를 제공하면 된다. 기능 확장에 열려있고 코드 변경에 닫혀있는 것이다.
[참고 1] 로버트 마틴
http://cleancoder.com/products