본문으로 건너뛰기

"backend" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

모든 태그 보기

Kotlin에서 JPA 사용할 때 주의할 점 (2) - Embeddable, IdClass

· 약 10분

Kotlin에서 JPA 사용할 때 주의할 점을 쓴 이후로 직장에서 하는 프로젝트에도 Kotlin + JPA를 사용하게 되었습니다. 그러다보니 좀 더 고급 기능을 사용하게 되고 또 여러가지 새로운 어려움에 부딪혔습니다.

Embeddable

기간(시작 날짜, 끝 날짜)이나 좌표(X, Y) 등 항상 같이 다니는 값들을 객체로 묶어서 Entity의 속성으로 지정할 수 있습니다.

이러한 객체의 클래스에 @Embeddable 어노테이션을 붙여서 선언하고, Entity에서 @Embedded 어노테이션을 붙여서 사용합니다. 예제 코드를 보면,

@Embeddable
data class Coordinate(
var x: Int,
var y: Int
)

와 같이 선언하고

@Entity
data class Marker(
@get:Id
var id: Int,

@get:Embedded
var coordinate: Coordinate
)

처럼 가져다 쓸 수 있습니다.

데이터베이스 스키마에서는 Coordinate에 대한 새로운 테이블이 생기지 않고, Marker 테이블에 x, y 컬럼이 추가됩니다.

주의사항

Embeddable 클래스도 이전 글에서 설명한 Entity 클래스와 마찬가지로 다음 속성을 만족해야 합니다.

Embedded와 null

JPA 스펙에 의하면 Embedded 속성은 null이 될 수 없습니다. 하지만 Hibernate 같은 구현체들은 null을 지원합니다.

당연히 일단 Kotlin에서 nullable 타입으로 수정해야 null을 넣을 수 있습니다.

@Entity
data class Marker(
@get:Id
var id: Int,

@get:Embedded
var coordinate: Coordinate? // <- nullable 타입으로 수정
)

그리고 실제 데이터베이스 스키마에서도 컬럼을 nullable하게 만들어야 합니다.

그런데 Hibernate에서 자동으로 테이블을 생성하는 경우(hbm2ddl.auto 사용시), Embeddable에 속한 컬럼은 무조건 not null 컬럼이 되는 문제가 있습니다. 그런 경우 다음과 같이 수정하면 nullable 컬럼이 생성되게 할 수 있습니다.

@Embeddable
data class Coordinate(
@get:Column
var x: Int,
@get:Column
var y: Int
)

참고로, Embeddable 클래스의 어노테이션 위치 (필드 vs 프로퍼티)는 포함된 Entity 클래스의 어노테이션 위치를 따라갑니다. 앞의 예제에서 Marker는 프로퍼티(getter)에 어노테이션을 달았기 때문에 Embeddable에서도 getter에 달아야 인식이 됩니다.

Kotlin에서 JPA 관련 어노테이션은 무조건 @get:으로 달아야 한다고 기억해두면 혼란이 적은 것 같습니다.

같은 타입의 Embedded 속성을 여러 개 선언하기

Kotlin과는 무관하지만 알아두면 좋은 내용입니다.

@Entity
data class Line(
@get:Id
var id: Int,

@get:Embedded
var start: Coordinate,

@get:Embedded
var end: Coordinate
)

위와 같이 선언하면 startend가 동일한 컬럼 x, y를 가지려고 해서 다음과 같은 오류가 발생합니다.

org.hibernate.MappingException: Repeated column in mapping for entity: org.sapzil.jpa.Line column: x (should be mapped with insert="false" update="false")

정석 해결 방법은 @AttributeOverride를 사용하는 것이지만 이런 속성이 많아지면 일일히 달기는 귀찮습니다. 이 때 ImplicitNamingStrategy를 이용하면 자동으로 start_x, end_x와 같이 prefix 붙은 컬럼을 지정할 수 있습니다.

Spring에서는 spring.jpa.hibernate.naming.implicit-strategy 프로퍼티를 org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl로 지정하거나, ImplicitNamingStrategyComponentPathImpl을 Bean으로 주입하여 설정하면 됩니다.

IdClass

JPA에서 복합 기본키(composite primary key)를 매핑하기 위해서는 @IdClass 어노테이션을 사용합니다. 다음과 같이 PK가 될 속성에 모두 @Id를 붙이고, PK의 속성을 모두 가진 클래스를 만들어서 @IdClass 어노테이션으로 지정합니다.

@Entity
@IdClass(Name::class)
data class Person(
@get:Id
var firstName: String,
@get:Id
var lastName: String,
var phoneNumber: String
)

어떤 클래스를 IdClass로 사용하려면 Serializable 인터페이스를 구현해야 돼서 처음에 다음과 같이 선언해 봤습니다.

data class Name(var firstName: String, var lastName: String) : Serializable

그랬더니 객체를 저장하려고 할 때 이런 오류가 발생했습니다.

java.lang.IllegalArgumentException: No argument provided for a required parameter: parameter #0 firstName of fun <init>(kotlin.String, kotlin.String): org.sapzil.jpa.Name
at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod(KCallableImpl.kt:138)
at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:110)
at org.springframework.beans.BeanUtils$KotlinDelegate.instantiateClass(BeanUtils.java:765)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:170)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:124)
...

메시지를 보니 아무래도 Spring이 생성자에 인자를 넘기지 않고 객체를 만들려고 해서 그런 것 같습니다. 그래서 인자를 받지 않는 생성자를 추가해봤습니다.

data class Name(var firstName: String, var lastName: String) : Serializable {
constructor() : this("", "")
}

그랬는데도 같은 오류가 발생했습니다. 스택 트레이스에 나타난 BeanUtils의 코드를 보면...

Constructor<T> ctor = (KotlinDetector.isKotlinType(clazz) ?
KotlinDelegate.getPrimaryConstructor(clazz) : clazz.getDeclaredConstructor());
return instantiateClass(ctor);

Kotlin 클래스일 경우 Primary Constructor를 찾게 되어있습니다. Primary Constructor는 클래스 선언 헤더에 같이 선언되는 생성자를 말합니다. 방금 추가한 인자 없는 생성자는 Secondary Constructor이기 때문에 인식되지 않은 것입니다.

그래서 data class를 포기해야 하나... 생각하고 있었는데, 구글링하던 중 Gist를 하나 발견했습니다. 결론은 생성자의 모든 파라미터에 기본값을 지정하면 된다는 것입니다.

data class Name(var firstName: String = "", var lastName: String = "") : Serializable

이렇게 하면 Primary Constructor를 인자 없이 호출할 수 있게 되어서 정상적으로 저장이 됩니다.

IdClass와 ImplicitNamingStrategy

(Kotlin과는 무관한 내용이지만) 앞서 설명한대로 ImplicitNamingStrategy를 변경하면 IdClass를 사용할 때 문제가 생길 수 있습니다.

ImplicitNamingStrategyComponentPathImpl을 사용할 때, 위의 예제대로 모델을 선언한 후 저장하려고 하면 이렇게 됩니다.

org.h2.jdbc.JdbcSQLException: NULL not allowed for column "_IDENTIFIER_MAPPER_FIRST_NAME"; SQL statement:
insert into person (phone_number, id_first_name, id_last_name) values (?, ?, ?) [23502-197]

먼저 컬럼 이름 앞에 id_가 붙은 것도 이상하고, _identifier_mapper_first_name이라는 이상한 컬럼이 생긴 것도 문제입니다.

IdClass를 지정하면 Hibernate는 내부적으로 id, _identifierMapper라는 숨은 속성을 생성합니다. 즉 id.firstName, _identifierMapper.firstName 같은 속성이 생기는 건데요. 기본 ImplicitNamingStrategy에서는 속성 경로의 마지막 부분만 취하기 때문에 문제가 없지만, 우리는 속성 경로를 모두 나타내는 전략으로 변경하였기 때문에 이런 이상한 일이 벌어진 겁니다. (결국은 HHH-11427 버그 때문입니다.)

이것을 제대로 해결하려면 ImplicitNamingStrategy의 구현을 수정해야겠지만, 간단하게는 컬럼명을 직접 지정해서 해결할 수 있습니다.

@Entity
@IdClass(Name::class)
data class Person(
@get:Id
@get:Column(name = "first_name") // <- 컬럼명 지정
var firstName: String,
@get:Id
@get:Column(name = "last_name") // <- 컬럼명 지정
var lastName: String,
var phoneNumber: String
)

정리

  • kotlin-jpa 컴파일러 플러그인 사용하자.
  • kotlin-allopen 컴파일러 플러그인 사용하자. (javax.persistence.Embeddable 추가해주자.)
  • Embeddable의 속성은 var로 선언하자.
  • 속성에 어노테이션 붙일 때는 getter에 붙이자. (@get:)
  • IdClass의 생성자에는 모두 기본값을 달아주자.
  • ImplicitNamingStrategyComponentPathImpl 쓰면 편리하다.
  • ImplicitNamingStrategyComponentPathImplIdClass 같이 쓰려면 컬럼명을 지정해주자.

Kotlin에서 JPA 사용할 때 주의할 점

· 약 9분

Kotlin에서 JPA를 사용해봅시다! Java에서 쓸 때와 별로 다를 것은 없습니다. 하지만 엔티티 클래스를 데이터 클래스로 선언하였을 때 런타임 프록시 객체를 사용하는 Hibernate/JPA의 기능들이 잘 작동하지 않을 수 있어 주의가 필요합니다.

프로젝트 세팅

예제 프로젝트는 Spring Boot를 사용하겠습니다. 하지만 다른 프레임워크에도 마찬가지로 적용되는 내용입니다.

build.gradle, Application.kt

크게 중요하지 않아서 Gist 링크로 대체합니다.

User.kt

엔티티 클래스입니다.

package org.sapzil

import javax.persistence.*

@Entity
data class User(@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
var name: String)

UserRepository.kt

package org.sapzil

import org.springframework.data.repository.CrudRepository

interface UserRepository : CrudRepository<User, Int> {
fun findByName(name: String): User?
}

Demo.kt

package org.sapzil

import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.test.context.junit4.SpringRunner
import javax.persistence.EntityManager

@RunWith(SpringRunner::class)
@DataJpaTest(showSql = true)
open class Demo {
@Autowired lateinit var userRepository: UserRepository
@Autowired lateinit var entityManager: EntityManager

@Before
fun setup() {
userRepository.save(User(name = "alice"))
userRepository.save(User(name = "bob"))
entityManager.clear()
}

@Test
fun simple() {
println(userRepository.findByName("bob"))
}
}

테스트 코드를 실행해보면 오류가 발생합니다.

org.springframework.orm.jpa.JpaSystemException: No default constructor for entity:  : org.sapzil.User; nested exception is org.hibernate.InstantiationException: No default constructor for entity:  : org.sapzil.User

기본 생성자가 없다고 합니다.

기본 생성자를 만들자

사실, 위의 엔티티 선언에는 문제가 있습니다. JPA 엔티티 클래스에는 기본 생성자(다른 말로는, 인자 없는 생성자)가 반드시 필요합니다.

당연히, 기본 생성자를 추가해주면 됩니다.

User.kt (수정 - 예시)

@Entity
data class User(...) {
constructor() : this(null, "")
}

하지만 모든 필드에 기본값을 채워줘야 하니 귀찮습니다. 어차피 JPA가 객체를 생성한 다음에 알아서 값을 채워줄텐데요.

kotlin-jpa 컴파일러 플러그인을 쓰면 @Entity 등의 어노테이션이 붙은 클래스에 자동으로 기본 생성자를 만들도록 할 수 있습니다. build.gradle에 다음 내용을 추가합니다.

buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version"
}
}

apply plugin: "kotlin-jpa"

실제로 만들어지는지 바이트코드를 까서 확인해봅시다. IntelliJ에서요. Gradle 임포트 후 Build > Rebuild Project 한 다음 User.kt에서 Tools > Kotlin > Show Kotlin Bytecode를 실행하면...

  // access flags 0x1
public <init>()V
L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lkotlin/Unit; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

기본 생성자가 추가된 것을 알 수 있습니다. 이제는 테스트도 통과합니다.

Hibernate: select user0_.id as id1_1_, user0_.name as name2_1_ from user user0_ where user0_.name=?
User(id=2, name=bob)

데이터 클래스로 선언했으니 toString()도 자동으로 구현된 것을 알 수 있습니다. 매우 편리하네요.

@ManyToOne과 지연 로딩 문제

이제 새로운 Post 엔티티를 추가해봅시다.

Post.kt

package org.sapzil

import javax.persistence.*

@Entity
data class Post(@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
var user: User,
var content: String)

PostRepository.kt

package org.sapzil

import org.springframework.data.repository.CrudRepository

interface PostRepository : CrudRepository<Post, Int>

Demo.kt (코드 추가)

@Autowired lateinit var postRepository: PostRepository

@Test
fun lazy() {
val bob = userRepository.findByName("bob")!!
val postId = postRepository.save(Post(user = bob, content = "Hello world")).id!!
entityManager.clear()
println("*** EntityManager cleared")

val post = postRepository.findOne(postId)
println("... Accessing post.user.id")
println(post.user.id)
println("... Accessing post.user.name")
println(post.user.name)
}

Post를 가져온 뒤, 연관된 User에 접근하는 테스트 코드입니다. 실행해봅시다.

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=? <- ???
... Accessing post.user.id
4
... Accessing post.user.name
bob

쿼리 로그에서 알 수 있듯이 Post를 가져올 때 User까지 동시에 가져와져 버렸습니다. 즉 지연 로딩이 작동하지 않은 것입니다.

지연 로딩을 하려면 프록시 객체를 만들어야 하는데, Kotlin의 모든 클래스는 final이라 상속을 받을 수 없습니다. 일반 클래스는 open할 수 있지만 데이터 클래스는 불가능합니다.

사실, JPA 표준에서는 엔티티 클래스가 final이면 안됩니다. 하지만 이 예제에서는 Hibernate를 JPA 구현체로 사용하기 때문에 어떻게든 작동하긴 하는 것 같네요.

아무튼 이번에도 컴파일러 플러그인의 도움을 받아서 엔티티 클래스를 확장할 수 있도록 만듭니다. build.gradle에 다음 내용을 추가합니다.

buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version"
}
}

apply plugin: "kotlin-allopen"

allOpen {
annotation "javax.persistence.Entity"
}

Gradle 임포트와 Rebuild를 한 다음 테스트를 다시 실행해보면...

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
... Accessing post.user.id
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
4
... Accessing post.user.name
bob

post.user에 접근할 때가 돼서야 User를 불러왔습니다. 이번에는 제대로 지연 로딩이 작동한 것을 확인할 수 있습니다.

연관 객체의 ID에 쿼리 없이 접근

아래 내용은 이제 사실이 아닙니다. Hibernate 5.2.13/5.3에서 문제가 수정되어 필드 접근 모드에서도 ID 접근시 엔티티가 로드되지 않습니다. 읽을 때 참고 바랍니다.

post.user.id는 사실 User를 쿼리해보지 않아도 post.user_id 컬럼으로 알아낼 수 있습니다. 알고보면 Hibernate에서 원래 지원하는 기능입니다. 하지만 위의 예제 코드에서는 작동하지 않았죠. 왜일까요?

데이터 클래스의 필드에 어노테이션을 달면, getter 메소드가 아니라 JVM 필드에 어노테이션이 달립니다. 그러면 프로퍼티 접근 모드가 아니라 필드 접근 모드가 되고, 이 경우 Hibernate는 지연 로딩을 지원하지 않습니다.

어쨌든 getter에 어노테이션이 붙도록 수정하면 됩니다.

User.kt (수정)

@Entity
data class User(@get:Id
@get:GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
var name: String)

Rebuild 후 테스트를 다시 실행해보면...

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
... Accessing post.user.id <- 이때는 쿼리가 안날아감
4
... Accessing post.user.name
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
bob

post.user.name에 접근할 때 User를 가져오는 것을 확인할 수 있습니다.

세 줄 요약

  • kotlin-jpa 컴파일러 플러그인을 써서 기본 생성자를 자동으로 추가하자.
  • kotlin-allopen 컴파일러 플러그인을 써서 엔티티 클래스를 상속 가능하게 만들자.
  • JPA 어노테이션은 getter에 달자.

고민해볼 점

  • 데이터 클래스를 쓸 때도 equals()hashCode()를 오버라이드 해야하나? toString()은?
  • 모든 필드를 데이터 클래스의 생성자에 선언해야 하나? @OneToMany 붙은 콜렉션은?
  • 이쯤되면 JPA가 문제인 것 같다. 굳이 JPA를 써야하나? 😂

RESTful API 서버 테스트하기

· 약 15분

요즘 소프트웨어 개발자라면 자동화 된 테스트가 필요하다는 것에 대부분 동의할 것입니다. 그러나 테스트를 짜본 적이 없으면 처음엔 어떻게 해야 할지 막막하기만 합니다. 저도 그랬고, 공부하려 해도 라이브러리 사용법이나 뜬구름 잡는 소리(TDD? BDD?)는 많은데 구체적인 방법은 찾아보기 힘든 것 같습니다. 그래서 이 글에서는 RESTful API 서버로 영역을 한정하여 하나의 테스트 작성 방식을 제시해보려 합니다.

애플리케이션 아키텍쳐에 대한 가정

특정 프레임워크에 한정되는 내용은 아니므로 일반적인 MVC 아키텍쳐를 가정하고 설명할 것입니다.

  • 모델: 데이터베이스에 접근하는 모듈. 서비스/DAO처럼 추상화 되어 있는 경우도 모델로 통칭하기로 합니다.
  • 컨트롤러: HTTP 요청을 받아 다듬은 다음 모델 계층을 호출해서 요청을 수행하고 결과를 돌려주는 모듈. 뷰, 요청 핸들러, 리소스라고 부르기도 합니다.
  • UI 테스트는 다른 접근 방식이 필요하기 때문에 뷰/템플릿은 고려하지 않고 JSON만을 돌려준다고 가정합니다.
  • 단일(monolithic) 애플리케이션을 가정합니다. 여러 작은 애플리케이션끼리 통신하는 아키텍쳐(SOA/마이크로서비스)에서는 다른 전략이 필요할 것입니다.

외부 인터페이스를 통해 통합 테스트

테스트에 대한 자료를 검색해보면 단위 테스트에 대한 것이 대부분인 것 같습니다. 단위 테스트는 하나의 모듈(클래스, 함수)을 격리시켜서 테스트하는 방법입니다. 한편 통합 테스트는 여러 모듈을 결합하여 시스템이 전체적으로 잘 동작하는지 테스트하는 방법이라고 할 수 있습니다.

비교적 외부에 노출되는 인터페이스가 명확한 RESTful API는 모듈을 각각 테스트하지 않아도 가장 상위 모듈을 테스트하면 하위 모듈도 대부분 커버됩니다. 구체적으로는 컨트롤러를 테스트하면 모델 계층도 거의 테스트됩니다. 따라서 통합 테스트만 작성하는 것을 기본으로 하고, 단위 테스트는 단위 테스트 방식으로 접근하면 노력이 덜 드는 경우에만 작성하는 것이 효율적입니다.

컨트롤러를 사용해서 테스트하는 방법은 컨트롤러 객체/함수를 직접 호출해서 테스트하는 방법과 HTTP 클라이언트를 사용하는 방법이 있습니다. 사용자는 HTTP를 통해 API에 접근할 것이고, 또한 URL 라우팅이나 미들웨어까지 포함해 테스트하기 위해서는 HTTP 클라이언트를 사용하는 것이 좋습니다. (물론 꼭 서버를 띄워서 소켓 통신을 해야 한다는 뜻은 아닙니다. 많은 프레임워크가 HTTP 요청을 시뮬레이션하는 방법을 제공하고 있으므로 그런 기능을 사용하면 됩니다.)

실제 데이터베이스를 사용

테스트 시 데이터베이스를 끼워넣는 전략이 여러가지 있습니다. 먼저 아예 데이터베이스를 사용하지 않는 방법입니다. 데이터베이스에 접근하는 인터페이스를 추상화하고 가짜 객체를 만듭니다. 이렇게 하면 데이터베이스의 동작을 매번 코드로 작성해줘야 하는 불편함이 있고 실제 환경과 거리가 멀어지므로 통합 테스트에는 적합하지 않습니다. 또한 SQL 기반의 RDBMS를 사용한다면 DB에도 로직을 어느 정도 맡기게 되므로 DB까지 포함해서 테스트하는 것이 좋다고 생각합니다.

실제 데이터베이스를 사용하더라도, ORM을 사용한다면 서비스에 쓰는 DB보다 가벼운 SQLite나 HSQLDB 같은 인메모리 DB를 사용해서 테스트할 수 있습니다. 하지만 ORM의 추상화가 항상 완벽하지는 않으므로 서비스에서 쓰는 것과 같은 종류의 DB를 띄워서 테스트하는 것이 좋습니다.

많은 프레임워크들이 테스트 시 DB를 비우는 기능을 제공하고 있기 때문에 개발 환경에 DB를 설치하는 부담을 제외하면 크게 불편함은 없는 것 같습니다. 요즘은 Docker 같은 기술도 있으니 로컬 개발 환경에 DB를 쉽게 설치할 수 있을 것입니다.

물론, 로컬 환경에 띄울 수 없는 외부 서비스는 추상 인터페이스를 만들고 가짜 객체를 사용하는 수 밖에 없겠습니다.

테스트를 작성할 예제 API

간단한 할일 목록 API를 테스트한다고 생각하겠습니다. 다음과 같은 API 엔드포인트가 있다고 합시다.

  • GET /tasks
    • 저장된 할일 목록을 최근에 추가된 것 먼저 돌려줍니다.
    • excludeCompleted=true 파라미터를 지정하면 완료되지 않은 목록을 돌려줍니다.
  • POST /tasks
    • 할일을 추가합니다.
    • 추가된 할일 객체를 돌려줍니다.

Task 객체는 다음과 같이 생겼습니다.

{
"id": "idididid",
"text": "Write tests",
"completed": false
}

테스트 작성하기

그럼 본격적으로 테스트를 작성해보겠습니다. 먼저 GET /tasks를 테스트해봅니다. 하나의 테스트 함수는 크게 세 단계로 나눠집니다.

  1. 원하는 상태를 준비
  2. 테스트할 API 호출
  3. 응답 및 상태 검증

저장된 할일 목록이 제대로 돌아오는지 확인하려면 할일을 저장해야 합니다. 데이터베이스를 직접 조작해서 할일을 저장할 수도 있습니다. 어떤 프레임워크는 미리 정해진 데이터(fixture)를 DB에 로드하는 기능을 제공합니다. 간단한 테스트에서는 이렇게 해도 무방하지만, 조금만 복잡해져도 fixture 데이터를 직접 작성하기 쉽지 않습니다. 테스트 코드와 테스트 데이터가 한 곳에 모이지 않게 되므로 테스트 데이터가 그렇게 만들어진 의도를 알아내기 힘들어질 수 있고 중복이 발생합니다. 또한 애플리케이션 차원에서 구현된 DB상 데이터의 제약을 제대로 지키기 어렵습니다.

따라서 외부 인터페이스를 통해 테스트한다는 원칙에 따라 POST /tasks를 호출해서 할일을 생성하겠습니다. 아직 테스트되지 않은 API를 사용한다는 것이 어색하게 느껴질 수 있지만 괜찮습니다.

# 파이썬 코드처럼 보이지만 파이썬이 아닐지도 모릅니다...
class TasksControllerTest(TestCase):
def setUp(self):
# 각 메소드 실행 전에 호출되는 메소드
self.client = Client()
self.reset_db() # 데이터베이스를 청소해주는 게 있다고 칩시다.

def testGet(self):
# 원하는 상태를 준비
task1 = self.client.post('/tasks', {'text': 'Write tests', 'completed': False}).json()
task2 = self.client.post('/tasks', {'text': 'Eat lunch', 'completed': True}).json()

# 테스트할 API 호출
response = self.client.get('/tasks')

# 응답 검증
assert response.statusCode == 200
assert response.json() == [task2, task1]

# 쓰기가 발생하는 API가 아니므로 호출 이후의 상태를 따로 검증하지는 않습니다.

이렇게 기본적인 동작을 테스트했습니다. 여기다가 할 일이 하나도 없는 엣지 케이스의 테스트 케이스를 하나 더 추가해볼 수 있을 겁니다.

    def testGet__empty(self):
response = self.client.get('/tasks')
assert response.statusCode == 200
assert response.json() == []

테스트 헬퍼 만들기

이제 GET /tasks API의 완료되지 않은 할일만 돌려주는 동작을 테스트해야 할텐데, 할일을 저장하는 코드의 중복이 생깁니다. 그리고 매번 text를 적어주기도 귀찮으니 적당히 자동으로 만들어주면 좋을 것 같습니다. 그러므로 할일을 만들어주는 함수를 따로 빼내겠습니다. 테스트 코드라고 해서 막 짜도 되는 것이 아니라, 실제 코드와 마찬가지로 읽고 관리하기 쉽도록 신경쓰는 것이 좋습니다.

    # 모든 필드를 입력하지 않아도 되도록 적당한 기본값을 넣어줍니다.
def _newTask(self, text=some_random_string(), completed=False):
return self.client.post('/tasks', {'text': text, 'completed': completed}).json()

물론 테스트 케이스 클래스에 메소드로 넣지 않고 일반 함수로 만들거나 Client를 상속받아서 그쪽에 추가하는 방법도 있습니다. 여튼 여기서 중요한 것은 테스트 환경을 준비해주는 공통 헬퍼를 만들었다는 점입니다.

이제 excludeCompleted=true인 경우의 테스트 케이스를 다음과 같이 테스트 헬퍼를 이용하여 작성할 수 있습니다. 기존 메소드도 리팩토링할 수 있겠죠.

    def testGet__excludeCompleted(self):
completedTask = self._newTask(completed=True)
doingTask = self._newTask(completed=False)
response = self.client.get('/tasks', {'excludeCompleted': True})
assert response.statusCode == 200
assert response.json() == [doingTask]

쓰기 API의 테스트

이제 POST /tasks API를 테스트해봅니다. 한 가지 주의할 점은, 이 API를 호출할 때 앞서 작성한 테스트 유틸리티를 사용하지 않는 것입니다. 테스트 대상이 되는 API에는 헬퍼 함수를 사용하지 않아야 헬퍼를 건드려서 의도치 않게 테스트의 동작이 바뀌는 일을 방지할 수 있습니다.

앞서 작성한 테스트와 다른 점은 API의 응답 외에도 API를 호출하고 난 뒤의 상태를 검증해야 한다는 것입니다. 이때도 데이터베이스를 직접 확인할 수 있지만 외부 인터페이스만을 사용한다는 원칙에 따라 GET /tasks를 호출해서 확인합니다.

    def testPost(self):
request = {'text': 'Drink coffee', 'completed': False}
response = self.client.post('/tasks', request)

# 응답 검증
assert response.statusCode == 200
created = response.json()
assert created['id']
assert created['text'] == request['text']
assert created['completed'] == request['completed']

# 상태 검증
assert self._getTasks() == [created]

def _getTasks(self):
# 이것도 테스트 헬퍼를 만들었습니다!
return self.client.get('/tasks').json()

결론

이상으로 RESTful API 서버의 테스트를 작성하는 방법을 간단히 살펴보았습니다. (너무 간단했나요?)

저는 외부 인터페이스만 사용해서 테스트라는 원칙을 알게 된 이후로 테스트 작성하는 것이 한결 수월해졌기 때문에 그 아이디어를 끝까지 밀고 가 보았습니다. 실제 API의 사용자가 어떤 순서로 API를 호출하게 될 것인지 상상하면서 테스트 코드를 만들 수 있어서인 것 같습니다. 하지만 정말 어쩔 수 없을 때는 DB에 직접 접근하는게 훨씬 쉬울 수 있으니 실용성을 따져가면서 코딩하시면 됩니다.

파이썬 웹 애플리케이션 배포: 고려할 점들

· 약 11분

파이썬 웹 애플리케이션을 배포할 때 고려할 점들이 많이 있습니다. 특히 선택할 수 있는 대안이 많으면 고민이 되는데요. 멋지게 '베스트 프랙티스'를 제시하면 좋겠지만 아직 그러기엔 많이 부족하고, 이 글에서는 제가 선택한 방식과 이유를 정리해서 공유해봅니다.

운영체제: Ubuntu

일반적으로 CentOS 아니면 Ubuntu를 사용하는 것 같습니다. Ubuntu를 선택한 가장 큰 이유는 '익숙해서'입니다. 그 밖에는 다양한 써드 파티 패키지(PPA)에 쉽게 접근할 수 있다는 점, 그리고 개발 환경으로 Ubuntu 데스크탑을 사용하면 배포할 때 환경 맞추기 훨씬 수월하다는 점이 있겠습니다. (CentOS를 데스크탑으로 쓰신다면야 말리지는 않겠습니다...) 버전은 굳이 LTS를 고집할 필요 없이 적당히 최신 버전을 쓰면 되는 것 같습니다.

WSGI 서버: Gunicorn

WSGI 서버로는 uWSGI 또는 Gunicorn이 많이 쓰입니다. uWSGI는 설정할 수 있는 파라미터도 많고 성능도 좋긴 하지만, Gunicorn은 성능이 크게 뒤지지 않으면서 훨씬 간단해서 관리하기가 수월하다고 생각합니다. 그리고 uWSGI는 graceful reload가 안돼서 재시작할 때 다운타임이 발생하는 것으로 보입니다. (해결 방법이 있다면 알려주세요.)

프로세스 관리: Upstart

대안이라면 supervisord가 있겠지만 Upstart는 Ubuntu에 기본으로 들어있어서 덜 귀찮죠. Upstart에 대해 자세히 알고 싶으시면 저번에 쓴 글을 참고하세요. 앞으로 Ubuntu가 systemd로 전환한다면 systemd를 사용하게 되겠죠.

웹 서버: nginx

이 선택은 딱히 논란의 여지가 없을 겁니다. 리버시 프록시와 정적 리소스 서빙에 사용합니다. 다만 반드시 Apache로 돌려야 하는 레거시가 있다면 예외입니다.

코드 배포: Git

애플리케이션 코드가 업데이트 되었을 때 어떻게 받아올 것인가 하는 이야기입니다. 정석대로(?) 하자면 Git 작업 사본과 실제 서비스되는 애플리케이션 코드를 분리하는 것이 좋습니다. 왜냐하면, git pull을 하는 동안 코드의 중간 상태가 노출될 수 있기 때문입니다. 예를 들어 이전 버전의 템플릿 파일과 새로운 버전의 템플릿 파일이 공존하게 된다면 서비스에 문제가 생길 수 있습니다. 하지만 대부분 아주 잠깐 발생하는 일이고, 템플릿 캐싱을 켜는 등 우회할 수 있는 방법이 있어서 큰 문제가 되지는 않습니다.

virtualenv나 설정 파일 등을 어디에 위치시킬 것인지도 고민하게 됩니다. 많은 경우 Git 작업 사본 안에 만들게 되는데, 그런 경우 .gitignore 파일에 확실히 추가하여 저장소에 들어가지 않도록 주의가 필요합니다.

돌아가는 서비스가 많을수록 헷갈리지 않도록 디렉토리 구조를 통일하면 도움이 됩니다. 애플리케이션 코드에서도 가급적 설정 파일 등의 위치나 파일명을 강제하지 말고 환경 변수에서 읽도록 하는 편이 좋습니다.

파이썬 패키지: virtualenv에 직접 설치

시스템에서 제공하는 바이너리 패키지를 설치할 수도 있지만 별로 권장하지 않습니다. 일단 virtualenv로 격리할 수도 없고, 최신 버전이 아닐 가능성이 매우 높고, 언제 업데이트가 될지도 알 수 없습니다. 다만 배포 대상 서버가 많다면 빌드 시간이 낭비되니까 직접 패키징하는 것을 고려해봐야 합니다.

추가로, 의존성을 setup.py로 관리할지 아니면 requirements.txt를 사용할지도 선택해야 하는데요. 이 쪽은 특별히 선호하는 방식이 있지는 않습니다. 하지만 많은 경우에 requirements.txt로만 관리해도 충분합니다. 어쨌든 관리가 되고 있다는 것이 중요합니다.

정적 리소스 배포

캐시 관리 전략: URL 기반

리소스가 갱신되었을 때 브라우저의 캐시를 어떻게 무효화 할 것인지 고려해봐야 합니다. 가장 확실한 방법은 리소스가 바뀔 때마다 URL을 바꾸는 것입니다. 아예 파일명을 바꾸거나 주소 뒤에 쿼리 문자열을 붙여서 (?v=3처럼) URL이 바뀌도록 해줍니다. 이런 일을 손으로 하면 실수할 가능성이 높으므로 가급적 자동으로 처리할 수 있는 시스템을 만드는 것이 좋습니다. 이렇게 하면 Expiry 헤더를 매우 길게 잡아 서버에 전혀 요청이 들어오지 않게 만들 수도 있어 성능상 이득이 있습니다.

내려주는 곳: 예산에 따라

서버가 한국에 있고, 접속자도 모두 한국 거주자라면 그냥 애플리케이션과 같은 서버에서 내려주면 되므로 특별히 고려할 것이 없습니다. 하지만 서버와 접속자의 위치가 다르고 충분한 예산이 있다면 CDN을 사용하는 것이 좋습니다.

CDN을 사용할 경우 S3와 같은 스토리지 서비스에 파일을 올릴지, 아니면 일반 웹 서버를 사용할지 선택해야 합니다. 스토리지 서비스를 사용할 경우에는 애플리케이션 배포 시 추가적인 리소스 배포 과정이 필요하지만 훨씬 안정적이라는 장점이 있습니다.

전처리: 상황에 따라

최근 웹 프론트엔드 개발에서는 SASS나 CoffeeScript처럼 전처리 과정이 필요한 언어를 사용하는 추세입니다. 크게 세 가지 방법이 있습니다.

  1. 배포하기 전에 미리 빌드: 배포할 서버 수가 많을 때 유리합니다. 혼자 개발하는 서비스라면 아예 Git 저장소에 같이 커밋할 수도 있습니다. 또한 스토리지 서비스를 통해 리소스를 내려줄 경우, 어차피 업로드하는 과정이 필요하므로 업로드하기 전에 해주면 됩니다.
  2. 서비스하는 서버에서 미리 오프라인 빌드: Git 훅 스크립트로 지정해두면 편하게 할 수 있습니다. 또한 애플리케이션 서버와 무관하므로 안전하다는 장점이 있습니다.
  3. 애플리케이션 서버에서 온라인 빌드: webassets 같은 라이브러리를 사용하여 애플리케이션 서버가 관리하도록 합니다. 서버 구동이 느려지거나 서비스에 영향을 미칠 수 있으므로 추천하는 방법은 아닙니다.

오류 추적: Sentry

실제 서비스를 시작하면 테스트할 때는 발견하지 못했던 예외가 발생할 수 있습니다. Sentry를 통해 오류를 수집하면 많은 도움이 됩니다. 또한 로거와 연동해서 치명적인 오류는 아니지만 예외적인 상황에 로그를 남기게 하면 비상시에 대처할 수 있어 좋습니다.

더 생각해볼 것들

  • 배포 자동화: Fabric으로 원격 배포를 자동화합니다.
  • 서버 설정 자동화: Chef, Puppet이나 Ansible 등을 사용해서 서버 세팅 과정을 형상 관리/자동화합니다.
  • 성능 모니터링: New Relic이 굉장히 좋지만, 가격이 만만치 않아서 아직 적절한 대안을 찾지 못했습니다.
  • 서버 접속 권한과 민감한 정보 관리
  • 업로드 된 파일 관리
  • 데이터베이스 / 메모리 캐시
  • 백그라운드 워커 (Celery) / 메시지 큐