로버트 마틴이 쓴 열고 닫기 원칙에서 Client-Server 예를 C#으로 이해하기

로버트 마틴이 쓴 열고 닫기 원칙에서 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

[참고 2] 로버트 마틴이 쓴 The Open-Closed Principle

[참고 3] Bertrand Meyer and The Open-Closed Principle

3 Likes

전략패턴

사용자가 추상화를 해 두어야 라이브러리로부터 독립할 수 있지요.

2 Likes