서비스 레이어 패턴 활용하기

서비스 레이어 패턴 (Service Layer Pattern)

애플리케이션의 비즈니스 로직을 별도의 계층(서비스 레이어)으로 분리하는 디자인 패턴. 이는 책임 분리(Separation of Concerns) 원칙을 따르며, 유지보수성과 확장성을 크게 향상시킨다. 일반적으로 서비스 레이어는 데이터베이스 접근, 비즈니스 규칙, 데이터 변환 등을 포함한다.

 

Service

핵심 비즈니스 로직을 처리하는 역할을 수행한다. 핸들러에서 요청받은 데이터를 기반으로 필요한 작업을 수행하며, 데이터베이스 호출, 상태 변경, 검증, 로직 처리 등을 담당한다.

 

Result

 

서비스 로직의 결과를 전달하는 객체로, 작업의 성공 여부, 에러 메시지, 처리된 데이터 등을 포함한다.

 

장점

코드의 가독성 향상

  • 핸들러는 비즈니스 로직과 분리되어 클라이언트 요청/응답 관리에만 집중할 수 있다.
  • 서비스는 순수하게 로직을 처리하므로 더욱 읽기 쉽고 유지보수하기 쉬워진다.

재사용성 증가

  • 서비스는 특정 로직을 캡슐화하므로, 다른 핸들러나 기능에서도 동일한 로직을 재사용할 수 있다.

테스트 용이성

  • 각 컴포넌트를 독립적으로 테스트할 수 있다.
    • 서비스는 특정 입력에 대한 출력(비즈니스 로직)이 올바른지 확인하는 단위 테스트 작성이 쉽다.
    • 핸들러는 외부 입력에 대해 서비스 호출과 응답 처리가 정상인지 테스트할 수 있다.

확장성

  • 서비스와 핸들러가 분리되어 있으므로, 기능 추가나 변경 시 영향 범위가 제한된다.
  • 예를 들어, 새로운 패킷이 추가되어도 기존 서비스 로직을 건드리지 않고 새로운 핸들러만 작성하면 된다.

에러 관리 용이

  • Result를 사용하면 성공/실패를 명확히 구분하고, 에러 메시지를 중앙에서 관리할 수 있다.
  • 에러 발생 시 클라이언트에 전달할 메시지를 일관되게 처리할 수 있다.

 

프로젝트 적용

서비스 레이어 적용 전 핸들러

public static async Task<GamePacketMessage> HandleJoinRandomRoomRequest(ClientSession client, uint sequence, C2SJoinRandomRoomRequest request)
  {
    try
    {
      // 유저 정보 확인
      var userInfo = UserModel.Instance.GetAllUsers().FirstOrDefault(u => u.Client == client);
      if (userInfo == null)
      {
        Console.WriteLine("[Lobby] JoinRandomRoom 실패: 인증되지 않은 사용자");
        return ResponseHelper.CreateJoinRandomRoomResponse(
          sequence,
          false,
          null,
          GlobalFailCode.AuthenticationFailed
        );
      }

      // 이미 방에 있는지 확인
      var existingRoom = RoomModel.Instance.GetUserRoom(userInfo.UserId);
      if (existingRoom != null)
      {
        Console.WriteLine($"[Lobby] JoinRandomRoom 실패: 이미 방에 있는 사용자 - UserId={userInfo.UserId}");
        return ResponseHelper.CreateJoinRandomRoomResponse(
          sequence,
          false,
          null,
          GlobalFailCode.JoinRoomFailed
        );
      }

      // 입장 가능한 방 목록 필터링
      var availableRooms = RoomModel.Instance.GetRoomList()
        .Where(r => r.Users.Count < r.MaxUserNum && r.State == RoomStateType.Wait)
        .ToList();

      if (!availableRooms.Any())
      {
        Console.WriteLine("[Lobby] JoinRandomRoom 실패: 입장 가능한 방이 없음");
        return ResponseHelper.CreateJoinRandomRoomResponse(
          sequence,
          false,
          null,
          GlobalFailCode.RoomNotFound
        );
      }

      // 랜덤하게 방 선택
      var random = new Random();
      var selectedRoom = availableRooms[random.Next(availableRooms.Count)];

      // 선택된 방 입장
      if (RoomModel.Instance.JoinRoom(selectedRoom.Id, userInfo.UserData))
      {
        Console.WriteLine($"[Lobby] JoinRandomRoom 성공: UserId={userInfo.UserId}, RoomId={selectedRoom.Id}");

        // 방에 있는 다른 유저들에게 새 유저 입장 알림
        var updatedRoom = RoomModel.Instance.GetRoom(selectedRoom.Id);
        if (updatedRoom != null)
        {
          var targetSessionIds = RoomModel.Instance.GetRoomTargetSessionIds(updatedRoom, userInfo.UserId);
          if (targetSessionIds.Any())
          {
            var notification = NotificationHelper.CreateJoinRoomNotification(
              userInfo.UserData,
              targetSessionIds
            );

            await client.MessageQueue.EnqueueSend(
              notification.PacketId,
              notification.Sequence,
              notification.Message,
              notification.TargetSessionIds
            );
          }
        }

        return ResponseHelper.CreateJoinRandomRoomResponse(
          sequence,
          true,
          updatedRoom,
          GlobalFailCode.NoneFailcode
        );
      }

      Console.WriteLine($"[Lobby] JoinRandomRoom 실패: 방 입장 실패 - RoomId={selectedRoom.Id}");
      return ResponseHelper.CreateJoinRandomRoomResponse(
        sequence,
        false,
        null,
        GlobalFailCode.JoinRoomFailed
      );
    }
    catch (Exception ex)
    {
      Console.WriteLine($"[Lobby] JoinRandomRoom Request 처리 중 오류: {ex.Message}");
      return ResponseHelper.CreateJoinRandomRoomResponse(
        sequence,
        false,
        null,
        GlobalFailCode.UnknownError
      );
    }
  }

 

 

 

물론 리펙토링을 진행하기 전인 코드이지만, 한 눈에 봐도 핸들러에 여러 기능들이 포함되어있음을 확인 가능하다. 만약 이 핸들러에 서비스 레이어 패턴을 적용시키면 아래와 같이 바뀌게 된다.

 

서비스 레이어 패턴 적용 후 핸들러

public static async Task<GamePacketMessage> HandleJoinRandomRoomRequest(ClientSession client, uint sequence, C2SJoinRandomRoomRequest request)
{
    var result = await _roomService.JoinRandomRoom(client);
    return ResponseHelper.CreateJoinRandomRoomResponse(
      sequence,
      result.Success,
      result.Data,
      result.FailCode
    );
}

핸들러는 패킷을 송수신하는 기능만을 담고 있고, 그 외의 기능은 Service 레이어로 분리되었다. 

 

Room 인터페이스

public interface IRoomService
{
  ...
  Task<ServiceResult<RoomData>> JoinRandomRoom(long userId);
  ...
}

 

Room 서비스

public async Task<ServiceResult<RoomData>> JoinRandomRoom(ClientSession client)
{
    try
    {
        var userInfo = _userModel.GetAllUsers().FirstOrDefault(u => u.Client == client);
        if (userInfo == null)
          return ServiceResult<RoomData>.Error(GlobalFailCode.AuthenticationFailed);

        var existingRoom = _roomModel.GetUserRoom(userInfo.UserId);
        if (existingRoom != null)
          return ServiceResult<RoomData>.Error(GlobalFailCode.JoinRoomFailed);

        var availableRooms = _roomModel.GetRoomList()
          .Where(r => r.Users.Count < r.MaxUserNum && r.State == RoomStateType.Wait)
          .ToList();

        if (!availableRooms.Any())
          return ServiceResult<RoomData>.Error(GlobalFailCode.RoomNotFound);

        var random = new Random();
        var selectedRoom = availableRooms[random.Next(availableRooms.Count)];

        if (!_roomModel.JoinRoom(selectedRoom.Id, userInfo.UserData))
          return ServiceResult<RoomData>.Error(GlobalFailCode.JoinRoomFailed);

        var updatedRoom = _roomModel.GetRoom(selectedRoom.Id);
        if (updatedRoom == null)
          return ServiceResult<RoomData>.Error(GlobalFailCode.RoomNotFound);

        await NotifyRoomMembers(client, updatedRoom, userInfo);
        return ServiceResult<RoomData>.Ok(updatedRoom);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"[RoomService] JoinRandomRoom 실패: {ex.Message}");
        return ServiceResult<RoomData>.Error(GlobalFailCode.UnknownError);
    }
}

 

Service Result

...
public class ServiceResult<T> : ServiceResult where T : class
{
    public T? Data { get; private set; }

    private ServiceResult(bool success, T? data = null, string message = "", GlobalFailCode failCode = GlobalFailCode.NoneFailcode)
        : base(success, message, failCode)
    {
        Data = data;
    }

    public static ServiceResult<T> Ok(T data, string message = "") => new ServiceResult<T>(true, data, message);
    public new static ServiceResult<T> Error(GlobalFailCode failCode, string message = "") => new ServiceResult<T>(false, null, message, failCode);
}

 

오늘은 서비스 레이어 패턴에 대해 알아보고 적용하는 시간을 가졌다. 요즘 책임/기능 분리 원칙을 최대한 지키면서 코드를 짜려고 하는데, 확실히 코드가 깔끔해진다. 더 깔끔하고 가독성 좋은, 그러면서도 성능이 뛰어난 코드를 짜는 그날까지... 나는 더 노력할 것이다!!!

 

 

 

 

'Side Projects' 카테고리의 다른 글

DI(의존성 주입)?  (0) 2025.01.12
분산서버 확립하기  (0) 2025.01.10
Fluent Validation? Bcrypt?  (0) 2024.12.08
Bull Queue?  (0) 2024.12.03
redis cluster 사용해보기  (1) 2024.11.27