스칼라를 이용한 자전거 대여 시스템 만들기

notJoon·2023년 8월 2일
1

OSSCA

목록 보기
2/2

저번에 ZIO 정기 모임에 참석했었습니다. 이번 모임에서는 DB에 데이터를 넣고 가져오는 방법에 대해 다뤄봤고, 실습으로 각자 어떤 서비스를 만들어보는 시간을 가졌었습니다.
저는 자전거 대여 시스템을 만들어보았습니다. 물론 당일에 완성은 못했고 이후에 따로 마무리지었습니다.

먼저 데이터베이스(DB)는 도커PostgreSQL을 이용했고, 당연하게도 언어는 스칼라, 라이브러리는 ZIO를 이용했습니다. 추가로 쿼리를 다뤄야 하기 때문에 doobie라는 라이브러리도 사용했죠.

이번 포스팅에서 도커를 이용해 데이터베이스를 만들고 데이터를 추가하는 방법을 전부 다루면 좋겠지만, 데이터베이스 생성은 생략하겠습니다. 하지만 방법을 간단한 명령어를 사용하는 것이기 때문에 참고자료에 관련 링크를 남겨놓겠습니다. 또한 전체 코드는 ZIO 모임 레포에서 확인할 수 있습니다

구조

서비스를 구현하기 위해 생각한 구조는 다음과 같습니다. 먼저 진입점인 App에서 (정확히는 BicycleRentalApp에서) ID와 비밀번호, 그리고 정류소 위치와 같은 정보들을 읽어들입니다.

서비스 구조

그다음 대여와 반납과 같은 비즈니스 로직은 BicycleRentalService에서 추상화하고, 이 레이어에서 필요한 메서드들은 User, Station, RentalRecord 클래스에서 호출합니다. 마지막으로 각 클래스는 기본적인 쿼리를 보내고 데이터를 가져오는 메서드들과 DB에 저장된 테이블의 구조를 담도록 했습니다.

데이터 생성 및 조작 그리고 연결

먼저 User, Station, RentalRecord의 각 클래스들은 DB의 테이블과 동일한 필드들을 가지고 있습니다. 먼저 도커에 저장된 데이터베이스들을 보겠습니다.

  • users : 유저 관련 데이터를 담는 역할을 합니다. 코드에서는 user이지만, 이 단어는 도커에서 예약어로 지정되어 있기 때문에 단수형으로 테이블을 생성하는 것은 불가능했습니다.
CREATE TABLE users (
    id text NOT NULL,
    password text,
    balance integer,
    PRIMARY KEY (id)
);
  • station : 대여소의 정보를 저장합니다.
CREATE TABLE station (
    stationId integer NOT NULL,
    availableBikes integer,
    PRIMARY KEY (stationId)
);
  • rental_record : 대여 기록을 저장하는 테이블입니다.
CREATE TABLE rental_record (
    userId text NOT NULL,
    stationId integer,
    endStation integer,
    rentalTime integer,
    cost integer,
    FOREIGN KEY (userId) REFERENCES users(id),
    FOREIGN KEY (stationId) REFERENCES station(stationId),
    FOREIGN KEY (endStation) REFERENCES station(stationId)
);

이제 각 데이터 테이블을 다루기 위해 클래스를 생성하겠습니다. case class를 이용하면 됩니다.

case class User(id: String, password: String, balance: Int);
case class Station(stationId: Int, availableBikes: Int);
case class rentalRecord(userId: Int, startStationId: Int, endStation: Option[Int], rentTime: Int, cost: Int);

이제 BicycleRentalApp을 데이터베이스에 연결하면 됩니다. 도커에서 PostgreSQL 컨테이너의 포트는 기본적으로 5432이기 때문에 이 포트 번호로 설정하겠습니다. 그럼 전체 코드는 다음과 같습니다.

// BicycleRentalApp.scala

object BicycleRentalApp extends ZIODefaultApp {
	
	// 이후에 로그인과 자전거 대여 서비스의 코드들이 들어갑니다. 
	private val prog = ???

	override def run = prog.provide(
		con >>> ConnectionSource.fromConnection >>> Database.fromConnectionSource
	)

	// 도커 컨테이너에 연결하기 위한 세팅입니다.
	val postgres = locally {
		val path = "localhost:5432"
		val name = ???
		val user = ???
		val password = ???
		
		s"jdbc:postgresql://$path/$name?user=$user&password=$password"
	}

	private val conn = ZLayer(
		ZIO.attempt(
			java.sql.DriverManager.getConnection(
				postgres
			)
		)
	)
}

사용자 데이터 다루기

이제 데이터베이스에 테이블도 추가했고 데이터를 다뤄볼 준비도 끝냈습니다. 쿼리를 날려보며 이것저것 추가하면 되는데, 먼저 유저 데이터를 추가해보겠습니다. 파일은 User.scala입니다.

package com.bicycle_db

import io.github.gaelrenoux.tranzactio.doobie.{Database, tzio}
import doobie._
import doobie.implicits._
import bicycle_db.UsersTableRow
import zio.ZIO

case class User(...)

class UserServices(db: database) {
	def insertUserTableRow(row: User): ZIO[Database, Throwable, Int] = {
		val insertQuery = tzio {
					(fr"insert into users (id, password, balance) values (" 
					++ fr"${row.userId}," 
					++ fr"${row.password}," 
					++ fr"${row.balance})").update.run
        }

			db.transactionOrWiden(insertUserTableQuery).mapError(e => new Exception(e.getMessage))
		}
	}
}

UserService.insertUserTableRow는 User 클래스에 DB에서 가져온 정보를 담는 메서드입니다. SQL 쿼리는 변수에 직접 바인딩하는 방식을 써도 되지만, SQL injection을 방지하기 위해 Doobie의 Statement Fragments 방식을 이용했습니다. 뭐 정확히는 저렇게 사용하는게 아니지만 연습삼아 해봤습니다.

이제 이 함수는 BicycleRentalApp에서 아래와 같이 데이터를 추가할 수 있습니다. 이렇게 추가한 데이터를 가지고 대여, 반납 시스템을 검증해보겠습니다.

object BicycleRentalApp extends ZIOAppDefault {

	val prog = for {
		userData <- userService.insertUserTableRow(Users("foobar", "password1", 1000))
		_ <- Console.printLine(userData)
	} yield()

	override def run = prog.provide(
    conn >>> ConnectionSource.fromConnection >>> Database.fromConnectionSource
  )

	// ...
}

run 함수에서 >>> 연산자는 함수 체이닝의 역할을 합니다. 예를 들어, 여기서는 connConnection.fromConnection으로 전달하고, 그 결과를 다시 Database.fromConnectionSource로 전달하는 식으로 동작합니다.

StationRentalRecord도 동일한 방법을 이용해 데이터를추가할 수 있습니다. 즉, 요렇게 나머지 함수들도 구현하면 데이터를 추가할 수 있습니다.

_ <- userService.insertUserTableRow(Users("foobar", "password1", 1000))
_ <- stationService.insertStationTableRow(Station(123, 10)) // start station
_ <- rentalRecordService.insertStationTableRow(Station(456, 10)) // end station

비즈니스 로직 추상화하기

이제 데이터베이스에 간단한 데이터를 넣어봤으니, 대여와 반납과 같은 비즈니스 로직을 구현해보겠습니다. 추가로 로그인시 등록된 회원인지 판별하는 기능도 넣어보겠습니다. 코드 위치는 BicycleRentalService.scala입니다.

package com.bicycle_db

import io.github.gaelrenoux.tranzactio.ConnectionSource
import io.github.gaelrenoux.tranzactio.doobie.{Database, tzio}
import zio._
import doobie._
import doobie.implicits._
import cats.implicits._

class BicycleRentalService(db: Database) {

    case class DatabaseError(message: String) extends Exception(message)

    def fromSqlException: PartialFunction[Throwable, DatabaseError] = {
        case e: java.sql.SQLException => DatabaseError(e.getMessage)
    }

    def rentBike(userId: String, stationId: Int, rentalTime: Int): ZIO[Any, Throwable, Int] = {
        val rentalRecord = RentalRecord(userId, stationId, None, rentalTime, 1000)
        val rentBicycleQuery = tzio {
            (fr"UPDATE users SET balance = balance -" ++ fr"${rentalRecord.cost}" ++ fr"WHERE id =" ++ fr"$userId").update.run *>
            (fr"INSERT INTO rentalRecord (userId, stationId, rentalTime, cost) VALUES (" ++ fr"$userId," ++ fr"$stationId," ++ fr"${rentalRecord.rentalTime}," ++ fr"${rentalRecord.cost}").update.run *>
            (fr"UPDATE station SET availableBicycles = availableBikes - 1 WHERE stationId =" ++ fr"$stationId").update.run
        }

        db.transactionOrWiden(rentBicycleQuery).mapError(fromSqlException)
    }

    def returnBike(userId: String, returnStationId: Int): ZIO[Any, Throwable, Int] = {
        val returnBikeQuery = tzio {
            (fr"UPDATE rentalRecord SET endStation =" ++ fr"$returnStationId" ++ fr"WHERE userId =" ++ fr"$userId" ++ fr"AND endStation IS NULL").update.run *>
            (fr"UPDATE station SET availableBikes = availableBikes + 1 WHERE stationId =" ++ fr"$returnStationId").update.run
        }

        db.transactionOrWiden(returnBikeQuery).mapError(fromSqlException)
    }

    def checkBikeAvailability(stationId: String): ZIO[Any, Throwable, Boolean] = {
        val checkBikeAvailabilityQuery = tzio {
            (fr"SELECT EXISTS (SELECT * FROM station WHERE stationId =" ++ fr"$stationId" ++ fr"AND availableBikes > 0)").query[Boolean].unique
        }

        db.transactionOrWiden(checkBikeAvailabilityQuery).mapError(fromSqlException)
    }

    def calculateRentalCost(rentalTime: Int): Int = {
        rentalTime * 1000
    }

    //// Login System ////

    def verifyUser(userId: String, password: String): ZIO[Any, Throwable, Boolean] = {
        val verifyUserQuery = tzio {
            (fr"SELECT EXISTS (SELECT * FROM users WHERE id =" ++ fr"$userId" ++ fr"AND password =" ++ fr"$password)").query[Boolean].unique
        }

        db.transactionOrWiden(verifyUserQuery).mapError(fromSqlException)
    }
}

순서대로 대여, 반납, 대여 가능 여부 판단, 요금 계산 그리고 로그인 시스템의 코드입니다.

  • rentBike - 자전거를 대여합니다. 먼저 사용할 시간 만큼 요금을 가져간 뒤, 대여 기록에 데이터들을 저장합니다. 그리고 대여한 정류소의 자전거 수를 1만큼 뺍니다.
  • returnBike - 자전거를 반납합니다. 해당 유저가 빌려간 기록에 반납 위치를 저장하고, 반납 장소의 자전거 댓수를 증가합니다.
  • checkBikeAvailability - 대여 가능한 자전거 수를 확인해 대여 가능 여부를 확인합니다.
  • calculateRentalCost - 요금을 계산합니다. 편의상 시간 * 1000(원)으로 설정했습니다.
  • verifyUser - 로그인시 해당 유저가 있는지 검사합니다. 단순히 users 테이블에서 id와 password 필드가 있는지 확인합니다.

추가로 에러타입을 좀 더 구체적인 타입으로 대체하기 위해 에러 유형을 정의한 뒤, ZIO의 mapError를 사용해 변환했습니다. 여기서는 doobie의 SqlState를 사용했고 fromSqlException이 이 역할을 합니다.

각 쿼리를 *>를 이용해서 체이닝 했는데, 이 메서드는 두 개의 표현식을 순차적으로 실행한 뒤, 첫 번째 표현식의 결과를 무시하고 두 번째 표현식의 결과를 반환합니다. 가제한 설명은 이후에 위에서 설명한 >>> 연산자와 함께 다루겠습니다.

REPL

유저의 입력을 받고 로그인-대여-반납을 처리하는 코드입니다. 각 클래스를 new로 인스턴스를 생성하고 각 서버스를 추가하면 됩니다.

object BicycleRentalApp extends ZIOAppDefault {

    val prog = for {
        _ <- ZIO.unit
        database <- ZIO.service[Database]

        // create services instances
        userService = new UserServices(database)
        stationService = new StationServices(database)
        rentalRecordService = new RentalRecordServices(database)
        rentalService = new BicycleRentalService(database)

        // login system
        _ <- Console.printLine("Enter your user id: ")
        userId <- Console.readLine
        _ <- Console.printLine("Enter your password: ")
        password <- Console.readLine

        // check if the user is verified or not. if not, fail the program
        isVerified <- rentalService.verifyUser(userId, password)
        _ <- if (isVerified) 
                Console.printLine("Log in") 
            else 
                ZIO.fail("Can't find user")

        // rent a bicycle
        _ <- Console.printLine("Enter the station id: ")
        stationId <- Console.readLine
        isAvailable <- rentalService.checkBikeAvailability(stationId)
        // if `isAvailable`, then get `rentTime` and proceed to rent a bicycle
        // else, fail the program
        _ <- if (isAvailable) {
                for {
                    _ <- Console.printLine("Enter the rental time: ")
                    rentalTime <- Console.readLine
                    rentalCost = rentalService.calculateRentalCost(rentalTime.toInt)
                    _ <- rentalService.rentBike(userId, stationId.toInt, rentalTime.toInt)
                    _ <- Console.printLine(s"Your rental cost is $rentalCost")
                } yield ()
            } else {
                ZIO.fail("No available bikes")
            }

        // return a bicycle
        _ <- Console.printLine("Enter the station id: ")
        returnStationId <- Console.readLine
        _ <- rentalService.returnBike(userId, returnStationId.toInt)
        _ <- Console.printLine("Bike has returned. Thank you for using our service!")
    } yield ()
		
		// ...
}

>>>, *> 연산자

위에서 사용한 연산자를 더 깊게 다뤄보겠습니다. >>> 연산자는 함수의 체이닝을, *>는 두 개의 표현식을 순차적으로 실행한 뒤 첫 번째 표현식의 결과를 무시하고, 두 번째 표현식의 결과를 반환하는 역할을 한다는 것을 이미 알아보았습니다.

각 연산자의 구현은 다음과 같이 되어있습니다.

// ZLayer.scala 

/**
 * Feeds the output services of this layer into the input of the specified
 * layer, resulting in a new layer with the inputs of this layer as well as
 * any leftover inputs, and the outputs of the specified layer.
 */
def >>>[E1 >: E, ROut2](that: => ZLayer[ROut, E1, ROut2])(implicit
  trace: Trace
): ZLayer[RIn, E1, ROut2] =
  ZLayer.suspend(ZLayer.To(self, that))

이 연산자 구현에서 ZLayer는 첫 번째 레이어 self를 실행한 뒤 나온 결과를 사용해 두 번째 레이어인 that을 생성합니다. ZLayer는 모나드의 개념을 활용하여 레이어를 조합하고 조작하는데 사용되며, 이런 방식으로 두 개의 레이어를 순차적으로 조합하고 새로운 레이어를 반환합니다. 모나드는 값을 감싸는 컨테이너로, 값을 추출하거나 다른 모나드로 변환하는 연산을 수행할 수 있습니다.

이 동작은 flatMap과 유사하다고 볼 수 있습니다. flatMap은 모나드 안의 값을 다른 모나드로 변환하는데 사용하는 함수입니다. 예를 들어, 스칼라의 Option에서 flatMap으로 >>>과 비슷한 동작을 하는 코드를 작성할 수 있습니다.

val option1: Option[Int] = Some(10)
val res: Option[String] = option1.flatMap(num => Some(s"Number is: $num"))

println(res) // Some(Number is: 10)

option1Some(10)으로 값이 존재하는 Option이고, flatMap 함수를 이용하여 option1의 값을 추출한 후 첫 번째 함수의 실행 결과를 res에 담아 반환합니다.

*> 메서드 역시 flatMap의 동작과 유사합니다. 아래와 같이 구현되어있습니다.

// Apply.scala

final def *>[A, B](fa: F[A])(fb: F[B]): F[B] =
    productR(fa)(fb)

*> 연산자는 모나드 F를 사용하여 두 개의 표현식 fafb를 순차적으로 실행한 후, 첫 번째 표현식 fa의 결과를 무시하고 두 번째 표현식 fb의 결과를 반환하는 연산자입니다. productR이 두 모나드 값을 받아서 두 번째 값을 반환하는 역할을 합니다.

*> 역시 flatMap과 유사하다고 볼 수 있습니다. 순차적인 실행을 수행하고, 첫 번째 모나드의 결과를 사용하지 않는다는 점에서 그렇습니다. 이 메서드를 flatMap을 사용해 구현할 때 Option 모나드를 이용하는 경우, 첫 번째 Option을 실행한 후 결과를 무시하고, 두 번째 Option을 반환하도록 만들면 됩니다.

val fa: Option[Int] = Some(10)
val fb: Option[String] = Some("foo")

val res: Option[String] = fa.flatMap(_ => fb)

println(res) // Some(foo)

fafb는 각각 Some(10)Some(”foo”)로 값이 존재합니다. flatMap 함수를 이용하여 fa의 실행 결과를 무시하고, fb를 반환하여 resSome("foo")가 할당되고, 이는 *>과 유사한 동작을 수행합니다.


참고 자료

  1. 도커를 이용한 PostgreSQL 데이터베이스 생성
profile
Uncertified Quasi-polyglot pseudo dev

1개의 댓글

comment-user-thumbnail
2023년 8월 2일

좋은 정보 감사합니다

답글 달기