Slick 개발시 아래와 같이 * method의 구현 코드를 작성하게 된다.

Table 생성시 (Int, String)의 Tuple type에 정의된 순서에 따라 (id, firstName) 형태의 구현이 필요하며,

정의된 Type의 범위를 벗어나는 경우 compile error가 발생한다.

class Hell extends Table[(Int, String)]("TB_HELL") {
  def id = column[Int]("HELL_ID")
  def firstName = column[String]("FIRST_NAME")

  def * = (id, firstName)
}

위의 코드에서 method * 는 column의 return type이 Rep class로 구성된 Tuple의 형태로 구현 혹은 할당된 모습으로 보인다.

column[C]의 정의는 아래와 같으며 Rep[C]를 return 하고 있다.

def column[C](n: String, options: ColumnOption[C]*)(implicit tt: TypedType[C]): Rep[C] = {
    /** ... **/
}

그러면 def *의 정의 구조를 살펴보자.

abstract class AbstractTable[T](val tableTag: Tag, val schemaName: Option[String], val tableName: String) extends Rep[T] {
  
  /** ... **/

  /** The * projection of the table used as default for queries and inserts.
    * Should include all columns as a tuple, HList or custom shape and optionally
    * map them to a custom entity type using the <> operator.
    * The `ProvenShape` return type ensures that
    * there is a `Shape` available for translating between the `Column`-based
    * type in * and the client-side type without `Column` in the table's type
    * parameter. */
  def * : ProvenShape[T] 
}

위와 같이 AbstractTable[T]의 내부 method로 정의되며 ProvenShape[T]가 Return Type이다.

 

그리면 이어서 ProvenShape[T]를 살펴보자.

trait ProvenShape[U] {
  def value: Any
  val shape: Shape[_ <: FlatShapeLevel, _, U, _]
  def packedValue[R](implicit ev: Shape[_ <: FlatShapeLevel, _, U, R]): ShapedValue[R, U]
  def toNode = packedValue(shape).toNode
}

위와 같은 형태의 trait으로 추론해보면,

(Rep[Int], Rep[String]) 형태의 원천 Tuple 데이터를 ProvenShape[(Int, String)] 형태로 변환하는 implicit type converstion이 어디인가 정의되어 있음을 추정할 수 있다.

(Rep[Int], Rep[String]) ------> ProvenShape[(Int, String)]

결론부터 말하자면,

적용된 implicit method를 명시적으로 호출되도록 구현을 하면 본질적으로 아래와 같은 호출 & 변환 구조라는 것을 알게 된다.

def * = ProvenShape.proveShapeOf(id, firstName)
		(tuple2Shape(repColumnShape(intColumnType), repColumnShape(stringColumnType)))

전체 implicit의 전체 처리 구조를 보기 좋게 재정리하면 아래와 같다.

눈에 보이는 것을 제외하곤 모든 것이 implicit 이다.

def * = ProvenShape.proveShapeOf(id, firstName)
       (
            tuple2Shape(
                repColumnShape(
                    intColumnType
                ), 
                repColumnShape(
                    stringColumnType
                )
            )
       )

 

우선 최상위 implicit 변환인 provenShapeOf 부터 살펴보면,

해당 변환의 정의는 object ProvenShape에서 찾을 수 있으며, 해당 object는 trait ProvenShape[T]의 동반 객체(Companion Object)로 해당 object내에 정의된 implicit def는 별도의 import 없이 ProvenShape[T]를 사용하는 스코프내에서 처리될 수 있다.

object ProvenShape {
  /** Convert an appropriately shaped value to a ProvenShape */
  implicit def proveShapeOf[T, U](v: T)(implicit sh: Shape[_ <: FlatShapeLevel, T, U, _]): ProvenShape[U] =
    new ProvenShape[U] {
      def value = v
      val shape: Shape[_ <: FlatShapeLevel, _, U, _] = sh
      def packedValue[R](implicit ev: Shape[_ <: FlatShapeLevel, _, U, R]): ShapedValue[R, U] = ShapedValue(sh.pack(value).asInstanceOf[R], sh.packedShape.asInstanceOf[Shape[FlatShapeLevel, R, U, _]])
    }
    
  /** ... **/
}

정의된 Tuple의 값이 아래의 provenShapeOf(A)(B) function을 통해 최종적으로는 ProvencShape[U]로 변환 처리됨을 알 수 있다.

implicit def proveShapeOf[T, U](v: T)
	(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _]): ProvenShape[U]

 

provenShapeOf는 2개의 parameter를 필요로 하는 currying이 적용된 method이다.

 

최초의 샘플코드를 기준으로 하나하나 살펴보면,

위의 Type T는 (Rep[Int], Rep[String])의 Tuple2에 해당되며, Type U는 (Int, String)의 Tuple2에 해당된다.

implicit parameter인 Shape는 Shape[Level, (M1,M2)(U1,U2), (P1,P2)]의 형태로 대응될 수 있다. 

 

하나하나 연관관계를 대응해보면 아래와 같이 표현할 수 있다.

ProvenShape.proveShapeOf(M1, M2)(Shape[Level, (M1,M2), (U1,U2), (P1,P2)])

M1 => Rep[Int]
M2 => Rep[String]
U1 => Int
U2 => String

 

첫번째 parameter는,

(v: T)의 T는 앞서 정의한 입력 parameter 값에 따라 정해지는 Type으로 (Rep[Int], Rep[String])의 형태의 두개의 값을 가지는 Tuple2의 Type으로 정의될 수 있다. (T = Tuple2)

 

두번째 parameter는,

아래와 같이 implicit 생성 규칙에 의해 생성된 Shape instance 이다.

호출시 사용된 parameter의 type인 Tuple2가 Type 인자 T로 정의되어 있음을 알 수 있고 Table 최초 정의시의  Schema인 (Int, String)이 U로 대응되는 Shape instance가 생성되어 있어야 함을 의미한다. 

(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _])

위의 Shape의 구조와 instance을 생성하는 implicit 의 구현을 살펴보자. 

Shape object에 3개의 implicits의 trait을 상속 받고 있으며, Tuple 구조의 데이터를 처리하는 TupleShapeImplicit를 역시 상속받고 있다.

모든 Shape instance에서 해당 traits이 적용 범위가 될 수 있음을 의미한다.

 

앞서 설명하였던 Tuple 이외에 다른 형태의 자료형을 처리하는 Implicit가 다른 trait에 정의 되어있음을 추정할 수 있다. 

(내용이 방대한 관계로 여기서는 Tuple을 처리하는 implicit 만 소개하도록 하겠다.)

abstract class Shape[Level <: ShapeLevel, -Mixed_, Unpacked_, Packed_] {
  /** ... **/
}

object Shape extends ConstColumnShapeImplicits 
	with AbstractTableShapeImplicits with TupleShapeImplicits {
  /** ... **/
}

trait TupleShapeImplicits {
  @inline
  implicit final def tuple2Shape[Level <: ShapeLevel, M1,M2, U1,U2, P1,P2]
  	(implicit u1: Shape[_ <: Level, M1, U1, P1], 
    		u2: Shape[_ <: Level, M2, U2, P2])
    : Shape[Level, (M1,M2), (U1,U2), (P1,P2)] =
    new TupleShape[Level, (M1,M2), (U1,U2), (P1,P2)](u1,u2)
    
  @inline
  implicit final def tuple3Shape[Level <: ShapeLevel, M1,M2,M3, U1,U2,U3, P1,P2,P3]
  	(implicit u1: Shape[_ <: Level, M1, U1, P1], 
    		u2: Shape[_ <: Level, M2, U2, P2], 
        	u3: Shape[_ <: Level, M3, U3, P3])
    : Shape[Level, (M1,M2,M3), (U1,U2,U3), (P1,P2,P3)] =
    new TupleShape[Level, (M1,M2,M3), (U1,U2,U3), (P1,P2,P3)](u1,u2,u3)
    
  /** ... **/
}

final class TupleShape[Level <: ShapeLevel, M <: Product, U <: Product, P <: Product]
	(val shapes: Shape[_ <: ShapeLevel, _, _, _]*) 
    extends ProductNodeShape[Level, Product, M, U, P] {
    
  override def getIterator(value: Product) = value.productIterator
  def getElement(value: Product, idx: Int) = value.productElement(idx)
  def buildValue(elems: IndexedSeq[Any]) = TupleSupport.buildTuple(elems)
  def copy(shapes: Seq[Shape[_ <: ShapeLevel, _, _, _]])  = new TupleShape(shapes: _*)
}

일단은 위의 tuple2Shape implicit 함수가 아래의 sh implicit parameter의 값(instacne)를 생성한다고 볼 수 있다.

implicit def proveShapeOf[T, U](v: T)
	(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _]): ProvenShape[U]

Type이 너무 많은 코드라 복잡하고 뭐가 뭔지 알 수가 없다. 갈 길을 잃을 타이밍이나 아래의 내용만 명심하면 된다.

 

tuple2Shape는 tuple2의 type구조를 반영하는 Shape 객체를 그저 만들어 내는 역할을 한다.

 

TupleShapeImplicits trait 파일을 실제로 살펴보면 Tuple 의 갯수별로 implicit function이 개별적으로 구현되어 있는데 입력되는 Tuple의 갯수가 한정적이고, 데이터 갯수에 따라서 1:1로 implicit conversion이 정의되어 있음을 알 수 있다. 

 

여기서 드는 의문은 동일한 TupeShape Class의 객체라 할지라도 선언된 Type parameter와 문맥이 일치하는 객체를 찾아 implicit converstion에 적용할 수 있느냐 이다. 아래의 예제를 살펴보자.

object TypeImplicits {
  abstract class Shape[A,B,C,D] {
    type RepType = B
//    def print = println("Type" + this.asInstanceOf[B])
    def print
  }

  trait ProvenShape[T] {
    def dsp
  }

  def main(args: Array[String]): Unit = {
    implicit def shapeA[A,B1,B2,C1,C2,D] : Shape[A, (B1,B2), (C1,C2), (D, D)] =
      new Shape[A,(B1,B2),(C1,C2),(D, D)] {
        def print = println("Tuple2 Shape")
      }
    implicit def shapeB[A,B1,B2,B3,C1,C2,C3,D] : Shape[A, (B1,B2,B3), (C1,C2,C3), (D, D)] =
      new Shape[A, (B1,B2,B3), (C1,C2,C3), (D, D)] {
        def print = println("Tuple3 Shape")
      }

    implicit def shapeOf[T, U](t : T)(implicit sh: Shape[_, T, U, _]) : ProvenShape[U] = 
      new ProvenShape[U] {
        def dsp = sh.print
      }

    val aa : ProvenShape[(Int, Int)] = (1, 2)
    aa.dsp
    val aaa : ProvenShape[(String, String, String)] = ("A", "B", "C")
    aaa dsp
  }
}

output:
Tuple2 Shape
Tuple3 Shape

implicit는 생각 이상으로 똑똑하다. 최조 정의된 data의 Type에 따라서 shapeA와 shapeB의 Type에 맞춰 구분하여 implicit conversion이 적용된다.

 

다시 본론으로 돌아와서,

다시 불행(?)하게도 tuple2Shape역시 두개의 Shape instance를 implicit parameter로 입력받고 있다. 

(implicit u1: Shape[_ <: Level, M1, U1, P1], u2: Shape[_ <: Level, M2, U2, P2])

M1 -> Rep[Int]
U1 -> Int
M2 -> Rep[String]
U2 -> String

최초 Table class에서 정의한 개별 Column에 1:1로 대응되는 개별의 Shape 객체가 implicit parameter로 처리되고 있다. 

해당 Shape parameter의 instance(Column의 Type별로 대응되는 Shape 객체)를 생성하는 implicit는 아래와 같다.

trait RepShapeImplicits extends OptionShapeImplicits {
  /** A Shape for single-column Reps. */
  @inline implicit def repColumnShape[T : BaseTypedType, Level <: ShapeLevel] 
  	= RepShape[Level, Rep[T], T]
}

실제 return object 생성의 구현은 아래의 RepShape object 에서 처리를 하고 있다.

object RepShape extends Shape[FlatShapeLevel, Rep[_], Any, Rep[_]] {
  def apply[Level <: ShapeLevel, MP <: Rep[_], U]: Shape[Level, MP, U, MP] = this.asInstanceOf[Shape[Level, MP, U, MP]]

  /** ... **/
}

 

특이한 점은 apply method 정의에서 별도의 입력 parameter가 없는데, 실제 처리에서는 아래와 같이 type과 관련된 매개변수를 전달받는 형태라는 것이다.

repColumnShape(intColumnType), repColumnShape(stringColumnType)

repColumnShape method의 parameter는 어디에 숨어있는가? 답은 해당 method의 정의에서 찾을 수 있다.

 

repColumnShape[T : BaseTypedType, Level <: ShapeLevel] 에서 T : BaseTypedType 는 context bound(한국말, 맥락 바운드)에 해당된다.  

 

context bound이 이해를 위해 아래의 간략한 예제를 살펴보자

/** ref.scala **/
trait Stype[A]
trait FruiteImplicit {
  @inline implicit def makeFruite[A: Stype] = Apple[A, A]
}

abstract class BaseFruite[A, B] {
  def name() : String
}

object Apple extends BaseFruite {
  def apply[A, B]: BaseFruite[A, B] = this.asInstanceOf[BaseFruite[A, B]]
  override def name(): String = "Apple!"
}

/** main.scala **/
object Main extends App {
  println(makeFruite(new Stype[Int]{}).name())
  
  implicit val sTypeInt = new Stype[Int] {}
  println(makeFruite.name())
}

output:
Apple!
Apple!

implicit def makeFruite[A: Stype]는 명시적으로는 parameter가 정의가 없는 형태이다. 

 

makeFruite은 정의에서 [A: Stype]의 context bound가 사용되고 있다. 암시적으로 (implict a: Stype[A]) parameter가 정의된다.

실질적으로는 아래와 같이 처리된다.

makeFruite[A, Stype](implicit a: Stype[A])

위의 예제에서 암시적 정의부를 명시적으로 호출하면 아래와 같이 정상적인 출력 결과 Apple! 을 확인할 수 있다.

println(makeFruite(new Stype[Int]{}).name())
// Apple!

다른 특이점으로 Apple object의 apply method 상에서 parameter 정의가 없으나 실제 호출시 명시적으로 값을 설정할 수 있으며, this.asInstanceOf[BaseFruite[A, B]]를 통하여 해당 object의 super class type의 instance를 반환하고 있다. (코드 패턴으로 보임)

 

다시 본론으로 돌아와서, 

아래의 reColumnShape는 context bound 로 정의( [T: BaseTypedType,..] )로 인하여 BaseTypedType[T]의 parameter를 역시나 암시적 매개변수(implicit parameter)로 처리된다.

repColumnShape(intColumnType), repColumnShape(stringColumnType)

// 참고
trait RepShapeImplicits extends OptionShapeImplicits {
  /** A Shape for single-column Reps. */
  @inline implicit def repColumnShape[T : BaseTypedType, Level <: ShapeLevel] 
  	= RepShape[Level, Rep[T], T]
}

그러면 위의 intColumnType, StringColumnType 은 어떤식으로 implicit로 정의되어 있을까?

/** JdbcTypesComponent.scala **/
trait JdbcTypesComponent extends RelationalTypesComponent { self: JdbcProfile =>
  trait ImplicitColumnTypes extends super.ImplicitColumnTypes {
    /** ... **/
    implicit def intColumnType = columnTypes.intJdbcType
    implicit def longColumnType = columnTypes.longJdbcType
    implicit def shortColumnType = columnTypes.shortJdbcType
    implicit def stringColumnType = columnTypes.stringJdbcType
    
  }
  
  class JdbcTypes {
    val instantType = new InstantJdbcType
    val stringJdbcType = new StringJdbcType
    /** ... **/
    
    class IntJdbcType extends DriverJdbcType[Int] with NumericTypedType {
      def sqlType = java.sql.Types.INTEGER
      def setValue(v: Int, p: PreparedStatement, idx: Int) = p.setInt(idx, v)
      def getValue(r: ResultSet, idx: Int) = r.getInt(idx)
      def updateValue(v: Int, r: ResultSet, idx: Int) = r.updateInt(idx, v)
    }
    
    class StringJdbcType extends DriverJdbcType[String] {
      /** ... **/
    }
    
    /** ... **/
  }
}

위와 같이 정의되어 있다. 각 Column의 data type에 1:1로 대응되는 객체를 필요할때마다 가져다 쓰는 느낌으로 보면 될 듯하다.

Slick의 자료구조는 계층적으로 매우 복잡한데 Context Bound로 정되었던 BaseTypedType[T]는 계층구조에서 아래와 같이 상위에 위치하고 있음을 확인할 수 있다.

그림을 마지막에 첨부한 이유는,

이 분석의 시작은 provenShapeOf 가 아닌 위의 Data 계층구조부터 시작하는게 맞는게 아니었나 하는 생각이 들었기 때문이다.

무엇이든 기반부터 이해하고 시작하는게 중요하지만 결코 쉽지는 않은 것 같다.

 

분석 후기

잘 알지도 못하는 Scala를 조금은 잘해보고 싶어서 시작한 분석이 한달이 넘게 걸릴 줄은 몰랐다. 

중간중간 막히는 Scala 코드의 패턴과 숨어있는 implicit를 찾아내느라.. 

어느 스칼라 고수의 말 처럼 '스칼라는 건드리는 거 아니다' 이게 답인지도 모르겠다.

Java와는 달리 Scala는 Type의 정의에 과도하게 집착하는 모습을 볼 수 있었고,

Slick 개발자는 반복과 비효율을 싫어하는지 implicit도 아주 과다하지만 계층적으로 잘 사용하고 있음을 확인할 수 있었다.

이러한 구현은 만드는 사람은 괴롭지만 쓰는 사람의 입장에서는 극단적으로 간결하고 Readabilty가 효율적인 코드를 구사할 수 있게 해준다.

물론 간결한 코드에 의문을 가지고 그 의문을 해결하고자 한다면 implicit와 Type의 악몽에 빠질 수 있음을 명심하자.

 

많이는 없겠지만 Scala 공부를 하는 자들을 위해 위의 코드를 극단적으로 단순화시킨 코드를 첨부한다.

코드 패턴을 연습해보자.

머리로 이해하면 문제는 풀 수 있지만 손이 이해해야 창조가 가능..

/** VirtualSlick.scala **/

object Virtual {
  val types = new JdbcTypes
  implicit def intCon = types.intJdbcType
  implicit def stringCon = types.stringJdbcType

  class Hell extends Table[(Int, String)]("TB_HELL") {
    def id = column[Int]("HELL_ID")
    def firstName = column[String]("FIRST_NAME")

    def * = (id, firstName)
  }

  def main(args: Array[String]): Unit = {

  }
}



/** VirtualSlickCore.scala **/

trait JdbcTypesComponent { self: JdbcProfile =>
  trait ImplicitColumnTypes {
    implicit def intColumnType = columnTypes.intJdbcType
    implicit def stringColumnType = columnTypes.stringJdbcType
  }
}

trait JdbcProfile {
  val columnTypes = new JdbcTypes
}

class JdbcTypes {
  val intJdbcType = new IntJdbcType
  val stringJdbcType = new StringJdbcType

  class IntJdbcType extends BaseTypedType[Int]
  class StringJdbcType extends BaseTypedType[String]
}

trait ProvenShape[U] {
  def value: Any
}

object ProvenShape {
  implicit def proveShapeOf[T, U](v: T)(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _]): ProvenShape[U] =
    new ProvenShape[U] {
      override def value: Any = ???
    }
}

abstract class Shape[Level <: ShapeLevel, -Mixed_, Unpacked_, Packed_] {
}

object Shape extends TupleShapeImplicits with RepShapeImplicits {
}

class TupleShape[Level <: ShapeLevel, A, B, C](val shapes: Shape[_ <: ShapeLevel, _, _, _]*) extends Shape[Level, A, B, C] {
}

trait BaseTypedType[T]
trait RepShapeImplicits {
  @inline implicit def repColumnShape[T : BaseTypedType, Level <: ShapeLevel] = RepShape[Level, Rep[T], T]
}

object RepShape extends Shape[FlatShapeLevel, Rep[_], Any, Rep[_]] {
  def apply[Level <: ShapeLevel, MP <: Rep[_], U]: Shape[Level, MP, U, MP] = this.asInstanceOf[Shape[Level, MP, U, MP]]
}

trait ShapeLevel
trait FlatShapeLevel extends ShapeLevel

trait TupleShapeImplicits {
  @inline
  implicit final def tuple2Shape[Level <: ShapeLevel, M1,M2, U1,U2, P1,P2](implicit u1: Shape[_ <: Level, M1, U1, P1], u2: Shape[_ <: Level, M2, U2, P2]): Shape[Level, (M1,M2), (U1,U2), (P1,P2)] =
    new TupleShape[Level, (M1,M2), (U1,U2), (P1,P2)](u1,u2)
  @inline
  implicit final def tuple4Shape[Level <: ShapeLevel, M1, M2, M3, M4, U1, U2, U3, U4, P1, P2, P3, P4](implicit u1: Shape[_ <: Level, M1, U1, P1], u2: Shape[_ <: Level, M2, U2, P2], u3: Shape[_ <: Level, M3, U3, P3], u4: Shape[_ <: Level, M4, U4, P4]): Shape[Level, (M1, M2, M3, M4), (U1, U2, U3, U4), (P1, P2, P3, P4)] =
    new TupleShape[Level, (M1, M2, M3, M4), (U1, U2, U3, U4), (P1, P2, P3, P4)](u1, u2, u3, u4)
}

trait Rep[T]
abstract class AbstractTable[T](val tableName: String) extends Rep[T] {
  type TableElementType = T
  def * : ProvenShape[T]
}
abstract class Table[T](tableName: String) extends AbstractTable[T](tableName) {
  def column[C](n: String) : Rep[C] = new Rep[C] {
  }
}

 

** 의역 문서 입니다.  (참고 http://www.scala-lang.org/node/107)


Case class는 일반적은 Parameter를 포함하는 생성자를 제공하는 일반적은 클래스와 패턴매칭을 통해 재귀적 객체 추출구성(recursive decomposition)을 제공한다.

아래는 clase class의 예제

abstract class Term
case class Var(name: String) extends Term
case class Fun(arg: String, body: Term) extends Term
case class App(f: Term, v: Term) extends Term


사용상의 편의를 위해서 calse class의 생성시 new 키워드를 사용하지 않아도 된다. 클래스 명을 함수같이 쓰면 된다.

아래의 예제 참고

Fun("x", Fun("y", App(Var("x"), Var("y"))))


생성자의 parameter는 모두 public 변수로 간주되며, 아래와 같이 직접 접근이 가능하다.

val x = Var("x")
Console.println(x.name)

 

모든 case class는 equal method(내부적으로 equality 규칙과 toString method가 재정의 된)가 컴파일러에 의해 자동 생성되어 아래와 같이 사용할 수 있다. 

val x1 = Var("x")
val x2 = Var("x")
val y1 = Var("y")
println("" + x1 + " == " + x2 + " => " + (x1 == x2))
println("" + x1 + " == " + y1 + " => " + (x1 == y1))


위의 실행 결과는 아래

Var(x) == Var(x) => true
Var(x) == Var(y) => false


case class는 아래와 같이 data structure를 구분하는데 사용될 수 있다.

object TermTest extends Application {
  def printTerm(term: Term) {
    term match {
      case Var(n) =>
        print(n)
      case Fun(x, b) =>
        print("^" + x + ".")
        printTerm(b)
      case App(f, v) =>
        Console.print("(")
        printTerm(f)
        print(" ")
        printTerm(v)
        print(")")
    }
  }
  def isIdentityFun(term: Term): Boolean = term match {
    case Fun(x, Var(y)) if x == y => true
    case _ => false
  }
  val id = Fun("x", Var("x"))
  val t = Fun("x", Fun("y", App(Var("x"), Var("y"))))
  printTerm(t)
  println
  println(isIdentityFun(id))
  println(isIdentityFun(t))
}

위의 예제이서, print 함수는 각 matching 상태를 나타내며, match 키워드에 해당 case의 구현체(body)에 matching 되는 형태를 취하고 있다.

isIdentityFun은 주어진 term이 simple identity 조건에 부합하는지를 검사하는 함수이다. 주어진 값이 패턴에 matching된 이후에 if 함수가 실행(evaluate)된다. 성공적으로 매칭된 경우 true를 return 하고 fail인 경우 다음 pattern matching을 시도하게 된다.

실행 결과는 아래와 같다.

^x.^y.(x y)
true
false
x == y는 scala 컴파일러에 의해 구현된 equal 함수에 의해 자동적으로 처리되었음을 확인 할 수 있다.


+ Recent posts