diff --git a/package-lock.json b/package-lock.json index 474807c0e..91745a548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11136,9 +11136,9 @@ }, "dependencies": { "minimist": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.2.1.tgz", - "integrity": "sha512-GY8fANSrTMfBVfInqJAY41QkOM+upUTytK1jZ0c8+3HdHrJxBJ3rF5i9moClXTE8uUSnUo8cAsCoxDXvSY4DHg==" + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.2.4.tgz", + "integrity": "sha512-Pkrrm8NjyQ8yVt8Am9M+yUt74zE3iokhzbG1bFVNjLB92vwM71hf40RkEsryg98BujhVOncKm/C1xROxZ030LQ==" } } }, @@ -13444,9 +13444,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" }, "minipass": { "version": "3.1.3", diff --git a/src/content/post/2023-04-24-DAO-Repository.md b/src/content/post/2023-04-24-DAO-Repository.md index 5f247b6bd..fdecf8b23 100644 --- a/src/content/post/2023-04-24-DAO-Repository.md +++ b/src/content/post/2023-04-24-DAO-Repository.md @@ -8,98 +8,88 @@ draft: false image: ../teaser/dao-repository.png --- -웹 자동차 경주 미션을 진행하며 많은 크루들이 DAO 클래스를 사용해서 DB에 접근했다. 대부분의 크루가 DAO 클래스에서 Spring의 @Repository 어노테이션을 사용하는 것을 확인했다. DAO가 -Repository의 일종인가, 싶었는데 DAO와 Repository를 함께 사용하는 크루도 있어서 혼란스러웠다. +DAO를 사용해서 DB에 접근할 때, 대부분 DAO 클래스에 Spring의 @Repository를 달아 둡니다. +_왜 DAO를 Repository라고 하지? DAO가 Repository의 일종인가?_ 싶었는데, DAO와 Repository라는 클래스를 함께 사용하는 코드도 꽤 있어서 매우 혼란스러웠습니다. +
-Spring 공식 문서의 @Repository 어노테이션의 설명에는 다음과 같이 나와 있다. + +조금 찾아보니, Spring 공식 문서의 @Repository에 대한 설명은 다음과 같습니다. > Teams implementing traditional Jakarta EE patterns such as “Data Access Object” may also apply this stereotype to DAO > classes, though care should be taken to understand the distinction between Data Access Object and DDD-style > repositories > before doing so. -위의 설명에 따르면 DAO 클래스에 @Repository 어노테이션을 사용할 수 있다. 다만 DAO와 [DDD](https://youtu.be/VIfNipL5KkU)에서 정의된 Repository의 차이를 잘 알고 -조심해서 사용해야 한다고 한다. -검색을 해본 결과, 두 개념이 엄연히 다르다는 것을 알 수 있었다. 이에 대해 나름대로 이해한 바를 아래의 차례로 정리해 보려 한다. +위의 설명에 따르면 DAO 클래스에 @Repository를 사용할 수 있지만, **DAO와 DDD에서 정의된 Repository의 차이**를 잘 알고 +조심해서 사용해야 한다고 합니다. +그러면 두 개념이 어떻게 다를까요? 나름대로 이해한 바를 아래의 차례로 정리해보겠습니다. + +1. DAO 패턴과 Repository 패턴의 등장 배경 +2. DAO 패턴 +3. Repository 패턴 + -1. DAO와 Repository의 필요성 -2. DAO Pattern -3. Repository Pattern -4. DAO와 Repository의 공통점과 차이점 +
-## DAO와 Repository의 필요성 +## DAO와 Repository의 등장 배경 Application을 구현할 때, [영속성(Data Persistence)](https://www.mongodb.com/databases/data-persistence)을 가진 영구저장소를 필요로 하는 경우가 -많다. 이때, Application에서 영구저장소에 접근하기 위해서는 각 영구저장소 벤더가 제공하는 API를 사용한다. 만약 영구저장소의 API가 다른 비즈니스 로직들과 함께 존재한다면 다음과 같은 문제들이 -발생한다. - -1. **구현체와 로직이 너무 강한 결합을 가진다.**
만약 기존의 영구저장소와 다른 벤더의 영구저장소를 사용하게 된다면, 비즈니스 로직들 내부에서 기존 영구저장소의 API를 사용한 모든 부분을 변경해야 할 - 것이다. 객체지향의 5원칙에는, 확장에 대해서는 열려있고 수정에는 닫혀 있어야 한다는 OCP(개방-폐쇄 원칙)가 존재한다. 위의 예시처럼 구현체(영구저장소)와 로직이 강한 결합을 가지게 되면 수정에 대해 - 열려있게 되므로 이를 위반하게 된다. -
-
+많습니다. Application에서 영구저장소에 접근하기 위해서는 각 영구저장소 벤더가 제공하는 API를 사용합니다. +이 때, 만약 영구저장소의 API가 다른 비즈니스 로직들과 함께 존재한다면 어떤 문제들이 발생할까요? + + +1. **구현체와 로직이 너무 강한 결합을 가진다.**
만약 기존의 영구저장소와 다른 벤더의 영구저장소를 사용하게 된다면, 비즈니스 로직들 내부에서 기존 영구저장소의 API를 사용한 모든 부분을 변경해야 합니다. 즉, 객체지향의 5원칙 중 확장에 대해서는 열려있고 수정에는 닫혀 있어야 한다는 OCP(개방-폐쇄 원칙)를 위반하게 됩니다. + 2. **계층화가 깨진다.**
- 일반적인 웹 Application의 구조는 아래와 같은 [layered architecture](https://www.baeldung.com/cs/layered-architecture)로 되어있다. -
- (출처 : https://velog.io/@jeb1225/DDD%EC%9D%98-%EA%B3%84%EC%B8%B5%EA%B5%AC%EC%A1%B0Layered-architecture) -

영구저장소는 infrastructure 계층에 속하고, 비즈니스 로직들은 Application 계층에 속하는데, 영구저장소에 대한 API가 application 계층에 속하게 되면 계층화가 깨진다. - Layered architecture의 이점인 모듈화, 유연성 등이 사라지게 되는 문제가 있다. + 보통 웹 Application의 구조는 아래와 같은 [Layered Architecture](https://www.baeldung.com/cs/layered-architecture)로 되어있습니다. + + 영구저장소는 Infrastructure 계층에 속하고, 비즈니스 로직들은 Application 계층에 속하는데, 영구저장소에 대한 API가 Application 계층에 속하게 되면 계층화가 깨지게 됩니다. + 그 결과, Layered Architecture의 이점인 모듈화, 유연성 등이 사라지게 되는 문제가 발생합니다. -그러므로 비즈니스 로직과 영구저장소의 API를 분리할 필요가 있다. 즉, 데이터에 접근하는 행위를 추상화하고 캡슐화하여 비즈니스 로직과 데이터 접근 로직을 분리해야 한다. 이를 구현한 Pattern으로 DAO -Pattern과 Repository Pattern이 있다. +그러므로 비즈니스 로직과 영구저장소의 API를 분리할 필요가 있습니다. +즉, 데이터에 접근하는 행위를 추상화, 캡슐화해서 비즈니스 로직과 데이터 접근 로직을 분리해야 합니다. 이를 구현하기 위한 방법으로 DAO 패턴과 Repository 패턴이 등장했습니다. -## DAO Pattern +
-DAO(Data Access Object)는 이름 그대로 데이터에 접근하기 위한 객체이다. 이름부터가 자신이 영속성 계층과 연관이 있음을 나타내고 있다. 그 자체가 영속성의 추상화이기 때문에 도메인 계층이 아닌 -영속성 계층에 속한다. 따라서 일반적으로 DAO는 DB의 테이블과 일치한다. 즉, 테이블 중심이라고 할 수 있다. +## DAO 패턴 + +DAO(Data Access Object)는 이름 그대로 데이터에 접근하기 위한 객체입니다. 이름부터 자신이 영속성 계층과 연관이 있음을 버젓이 나타내고 있습니다. 그 자체가 영속성의 추상화이기 때문에 도메인 계층이 아닌 +**영속성 계층에 속합니다**. 그래서 도메인 패키지에 DAO가 있으면 어색한 느낌을 줍니다. 일반적으로 DAO는 DB의 테이블과 일치합니다. 즉, 테이블 중심이라고 할 수 있습니다. +필연적으로, DAO와 매핑되는 엔티티는 테이블의 칼럼과 (거의) 동일한 필드를 가지게 됩니다. +아래의 코드 예시를 살펴보겠습니다. ```java public class Car { + + private final Long id; private final String name; private final int position; public Car(String name, int position) { + this.id = null; this.name = name; this.position = position; } + //... - public CarDto toDto() { - return new CarDto(name, position); - } + } ``` +위와 같이 Car라는 클래스가 존재한다고 하겠습니다. -```java -public class CarDto { - private final String name; - private final int position; - - public CarDto(String name, int position) { - this.name = name; - this.position = position; - } - - public Car toCar() { - return new Car(name, position); - } - - //getter etc.. -} -``` - -위와 같이 Car와 CarDto 클래스가 존재한다고 하자.
이제 Car 객체의 데이터를 DAO를 사용하여 영속성 계층에 CRUD를 할 것이다. Car와 DAO는 존재하는 계층이 다르기 때문에 DTO로 통신하는 것이 합리적이다. -데이터를 영구저장소에 CRUD하는 메서드가 있는 CarDao 인터페이스를 만든다. 그리고 각 영구저장소의 벤더에 따라 DAO 인터페이스의 구현체를 만든다. 각 구현체에서는 해당하는 영구저장소의 API들이 사용된다. -
아래 예시는 DAO 인터페이스와 해당 인터페이스의 MySQL, MongoDB 각각에 대한 축약된 구현체들이다. +
+이제 Car 인스턴스의 데이터를 DAO를 사용하여 영속성 계층에 CRUD를 해보겠습니다. +이 때, 일반적이지는 않지만 MySQL과 MongoDB 모두 사용하는 Application이라고 가정하겠습니다. 각 영구저장소의 벤더에 따라서 DAO를 만들어야 하므로 저는 DAO 인터페이스를 하나 만들어서 추상화하려고 합니다. 그리고 각 영구저장소의 벤더에 따라 DAO 인터페이스의 구현체를 만듭니다. 각 구현체에서는 해당하는 영구저장소의 API들을 사용합니다. ```java public interface CarDao { - void create(CarDto carDto); + void create(Car car); - CarDto read(int id); + Car read(Long id); - void update(CarDto carDto); + void update(Car car); - void delete(int id); + void delete(Long id); } ``` @@ -109,18 +99,19 @@ public class MySQLCarDao implements CarDao { //DB connection etc... @Override - public void create(CarDto carDto) { + public void create(Car car) { String sql = "INSERT INTO CARS(id, name, position) VALUES(?, ?, ?)"; //parameter binding etc... } @Override - public CarDto read(int id) { + public Car read(Long id) { String sql = "SELECT * FROM CARS WHERE id = ?"; //parameter binding etc... } //... + } ``` @@ -130,14 +121,14 @@ public class MongoDBCarDao implements CarDao { //DB connection etc... @Override - public void create(CarDto carDto) { + public void create(Car car) { //doc -> Document 인스턴스 - doc.put(carDto.getName(), carDto.getPosition()); + doc.put(car.getName(), car.getPosition()); //etc... } @Override - public CarDto read(int id) { + public Car read(Long id) { //MongoCollection의 find() 메서드 이용 Document doc = collection.find(eq("id", id)); //etc... @@ -147,123 +138,117 @@ public class MongoDBCarDao implements CarDao { } ``` -이처럼, 직접적인 DB와의 상호작용을 추상화하고 쿼리를 실행하는 객체를 DAO라고 하고, DAO를 이용하여 데이터에 접근하는 Pattern을 DAO Pattern이라고 한다. +이처럼, 직접적인 DB와의 상호작용을 추상화하고 쿼리를 실행하는 객체를 DAO라고 하고, DAO를 이용하여 데이터에 접근하는 패턴을 DAO 패턴이라고 합니다. -## Repository Pattern - -Spring 공식 문서에 있는 @Repository 어노테이션에 대한 설명은 다음과 같다. - -> Indicates that an annotated class is a “Repository”, originally defined by Domain-Driven Design (Evans, 2003) as “a -> mechanism for encapsulating storage, retrieval, and search behavior which emulates a collection of objects”. +
-위의 정의에서 나와 있듯이, Repository는 단순히 저장소라는 의미이다. 이름에서는 전혀 영속성 계층과 연관이 있음을 알 수 없다. (그리고 실제로 연관이 없을 수도 있다!) 그저 객체들의 집합(collection)을 추상화한 메커니즘이다. -도메인과 아주 밀접한 관계가 있고, 영속성 계층과의 연관이 불확실하므로 Repository의 인터페이스는 도메인 계층에 속한다. +## Repository 패턴 -아래는 CarRepository 인터페이스와, 메모리와 DB 각각에서 사용될 해당 인터페이스의 축약된 구현체들이다. -
(아래 예시에서, DBCarRepository처럼 영속성 계층과 통신하는 경우는 메서드들의 파라미터로 DTO가 오는 것이 합리적일 것이다. 그러나, InMemoryCarRepository처럼 계층 내부 간의 통신인 경우가 있어서 예시에서는 -Car 객체를 직접 전달받았다.) +네이버 사전에 등록된 Repository의 의미는 아래와 같습니다. +![](https://i.imgur.com/CkBosVS.png) -```java -public interface CarRepository { - Car findById(int id); +저장소라는 뜻이네요. +Spring 공식 문서에 있는 @Repository에 대한 설명도 다음과 같습니다. - List findAll(); +> Indicates that an annotated class is a “Repository”, originally defined by Domain-Driven Design (Evans, 2003) as “a +> mechanism for encapsulating storage, retrieval, and search behavior which emulates a collection of objects”. - void save(Car car); +즉, Repository는 단순히 저장소라는 의미입니다. 이름에서는 전혀 영속성 계층과 연관이 있음을 알 수 없습니다. (그리고 실제로 연관이 없을 수도 있습니다!) 그저 객체들의 집합(Collection)의 관리를 추상화한 메커니즘일 뿐입니다. +도메인과 아주 밀접한 관계가 있고, 영속성 계층과의 연관이 불확실하므로 **Repository의 인터페이스는 도메인 계층에 속해야합니다**. 무슨 말인지 감이 오시나요? - void deleteById(int id); -} -``` +조금 더 명확한 설명을 위해 코드를 한번 작성해보겠습니다. ```java -public class InMemoryCarRepository implements CarRepository { - private Map cars = new HashMap<>(); +public class Cars { - @Override - public Car findById(int id) { - return cars.get(id); + private final List cars = new ArrayList<>(); + + public void save(Car car) { + cars.add(car); } - - @Override - public List findAll() { - return cars.values() - .stream() - .collect(Collectors.toList()); + + public Car read(int index) { + return cars.get(index); + } + + public void delete(int index) { + cars.remove(index); } - - //... } ``` +Car의 일급컬렉션인 Cars는 필드로 Car의 리스트를 가지고 있습니다. 그리고 이 리스트에 Car를 저장하고, 가져오고, 삭제하는 메서드를 제공하고 있습니다. +그러면 우리는 이 Cars를 Car의 In-memory Repository라고 부를 수 있습니다. 말 그대로 저장소(Car 리스트)의 관리 역할을 하니까요! +그러면 이 Cars라는 클래스는 어디에 위치해야 할까요? Car가 존재하는 도메인 패키지에 있는 것이 자연스럽겠죠? -```java -public class DBCarRepository implements CarRepository { - private MySQLCarDao mySQLCarDao; - private MongoDBCarDao mongoDBCarDao; - private JdbcTemplate jdbcTemplate; - - //constructor etc... +이처럼, `객체의 정보를 가진 저장소에 대한 관리`에 대한 책임을 위임받은 인터페이스인 Repository를 사용한 패턴을 Repository 패턴이라고 합니다. +저장소는 In-memory에 위치할 수도 있고, Application 외부의 어떤 file이 될 수도 있습니다. 어디에 저장하는지가 중요한 것이 아니라, 그냥 어떤 저장소에 데이터를 넣고, 읽어오고, 삭제하는 역할을 해주는 것이 Repository의 역할이니까요. - @Override - public Car findById(int id) { - return mySQLCarDao.read(id).toCar(); +마찬가지로, Car의 데이터를 영속성 계층에 저장할 때에도 Repository 패턴을 사용할 수 있습니다. +```java +public class CarDatabaseRepository { + + public void save(Car car) { + // Database에 Car를 저장 } - - @Override - public List findAll() { - List cars = new ArrayList<>(); - for (int id : 전체 id 컬렉션){ - cars.add(mongoDBCarDao.read(id).toCar()); - } - return cars; + + public Car find(Long id) { + // Database에서 Car를 조회 } - - @Override - public save(Car car) { - String sql = "INSERT INTO CARS (name, position) VALUES(?, ?)"; - - jdbcTemplate.update(sql, car.getName(), car.getPosition()); - //... + + public void delete(Long id) { + // Database에서 Car를 삭제 } - //... } ``` +여기서, DAO와 Repository가 혼동이 생깁니다. Repository가 관리하는 저장소가 영속성 계층에 위치할 때, 이 Repository는 DAO와 동일하게 보이거든요. CarDao와 CarDatabaseRepository가 비슷하게 보이지 않나요? +그러나 "_영속성 계층에 위치한 저장소를 관리하는 Repository는 DAO다!_"라고 하기 어려운 개념적인 이유가 있습니다. -InMemoryCarRepository는 내부의 HashMap에 Car들을 저장하여 가지고 있다. 그리고 해당 HashMap에 Car를 저장, 삭제, 검색 등을 한다. 만약 Car의 정보가 저장되는 곳이 외부 -파일이라면 FileCarRepository 등의 Repository 인터페이스의 구현체를 만들 수 있을 것이다. -이처럼 Repository는 영구저장소와 무관하게, 객체의 정보를 저장하고 읽어오는 역할에 대한 추상화가 가능하다면 사용할 수 있다.
-DBCarRepository는 DAO들을 내부에서 이용하거나, jdbcTemplate을 사용해서 DB에 접근하고 있다. 설명을 위해 억지스러운 예를 들었지만, 이처럼 어떤 방법을 사용하던 목적인 저장소에 -객체의 정보를 저장하고 관리한다면 Repository의 구현체로 사용될 수 있다. +혹시 CarDao와 CarDatabaseRepository의 차이를 눈치채셨나요? +네, CarDatabaseRepository에는 Update에 관한 메서드가 존재하지 않습니다. 왜 그럴까요? 아래의 코드로 해당 개념을 설명하겠습니다. -이처럼, `객체의 정보를 가진 저장소에 대한 관리`에 대한 책임을 위임받은 인터페이스인 Repository를 사용한 Pattern을 Repository Pattern이라고 한다. -
-
-
-눈치를 챈 사람도 있겠지만, Repository 인터페이스에는 DAO와 다르게 update 메서드가 빠져있다. [StackOverFlow](https://stackoverflow.com/questions/8550124/what-is-the-difference-between-dao-and-repository-patterns) -에는 다음과 같이 적혀있다. +```java +public void updateName() { + Car car1 = new Car("머스탱", 3); + Car car2 = new Car("박스터", 1); + + Cars cars = new Cars(); + + cars.save(car1); + cars.save(car2); + + car1.setName("리오의 머스탱"); + car2.setName("박스터의 박스터"); + + Car savedCar1 = cars.read(0); + Car savedCar2 = cars.read(1); + + System.out.println(savedCar1.getName()); // 리오의 머스탱 + System.out.println(savedCar2.getName()); // 박스터의 박스터 +} +``` +위의 코드에서 마지막 출력 때 주석에 달린 대로 각 Car들의 변경된 이름이 나오는 것이 너무나 자연스러워보입니다. savedCar1이 Car1과 동일하고, savedCar2가 Car2와 동일할테니까요. +이게 바로 Repository에는 Update 메서드가 없는 이유입니다. + +[StackOverFlow의 글](https://stackoverflow.com/questions/8550124/what-is-the-difference-between-dao-and-repository-patterns) +에 이런 내용이 있습니다. > A method like Update is appropriate on a DAO, but not a Repository. When using a Repository, changes to entities are > usually tracked by a separate UnitOfWork. -이에 따르면 일반적으로 Repository는 update 메서드를 가지는 것이 어색하다. 여기에서 DAO와 Repository의 개념적 차이가 명확하게 나타난다. -CRUD는 DB의 가장 기본적인 기능이고, DAO는 DB의 테이블과 밀접하게 연관되어 있다. 그러므로 해당 테이블에서 어떤 레코드를 update 하는 것이 DAO와 관련이 되어도 전혀 어색하지 않다. -
그러나 Repository는 DB에 국한되지 않은, 객체의 정보를 저장한 `저장소의 관리`에 대한 책임을 지고 있다. -"객체를 저장한다.", "저장된 객체를 조회한다.", "저장된 객체를 삭제한다."라는 행위들은 이 책임에 해당한다. -하지만 "update할 객체의 정보와 update될 값을 가지고, 저장소에 이미 저장된 객체를 조회해서 그 객체의 정보를 update한다"라는 행위는 객체의 저장소에 대한 책임이라고 하기에는 무리가 있다. -또한 위의 설명대로, 엔티티의 수정 내역은 보통 분리된 "[UnitOfWork](https://zetlos.tistory.com/1179902868)"에 따르기 때문에 Repository에서 일어나는 것이 적절하지 않다. +CRUD는 DB의 가장 기본적인 기능이고, DAO는 DB의 테이블과 밀접하게 연관되어 있습니다. 그러므로 DAO에 Update 메서드가 존재하는 것이 너무나 당연합니다. +그러나 Repository는 다릅니다. 위의 코드에서처럼 Car1과 Car2가 Cars 내부의 Car리스트에 담긴다고 해서 새로운 객체가 되는 것이 아니기 때문에, 이름을 변경하면 당연히 변경된 이름이 나올 겁니다. +그런데 저장소가 영속성 계층에 위치한다면 어떨까요? application에서 Car1과 Car2의 이름을 바꾸면 자동으로 DB Row 내용도 변할까요? 당연히 바뀌지 않을 겁니다. 그치만 Repository 패턴을 사용한다면 DB의 내용도 당연하게 바뀌어야 합니다. 저장소가 어디에 위치하냐에 따라서 Repository의 책임이 달라지면 안되니까요. 그러므로 외부 저장소(DB나 File 등)에 대한 Repository는 Update 메서드 없이 객체의 변경이 저장소에 반영되도록하는 기능을 지원해야합니다. +이것이 Repository가 Update 메서드를 지원하지 않는 이유이고, JPA의 영속성 컨텍스트에서 `변경 감지`가 이루어지는 이유입니다. 인용한 문장에 있는 [UnitOfWork](https://zetlos.tistory.com/1179902868)라는 디자인 패턴의 개념을 간단하게 설명해봤습니다. 더 자세히 알고 싶으시다면 링크된 블로그를 참고해주세요! -## DAO와 Repository의 공통점과 차이점 - -위에서 설명했다시피, DAO와 Repository 모두 데이터에 대한 접근을 추상화하고 캡슐화하여 비즈니스 로직과 데이터 접근 로직을 분리하는 데에 사용된다. - -DAO는 영속성의 추상화이고, Repository는 객체들 집합(collection)의 추상화이다. -DAO는 storage system에 더 가까운 개념이고 Repository는 도메인 객체에 가까운 개념이다. -Repository가 상대적으로 더 high-level의 개념이다. -그러므로 Repository는 DAO를 사용해 구현할 수 있으나, 그 반대는 구현할 수 없다. +--- +### 마치며 -이상으로 DAO와 Repository에 대해 알아봤다. 간단한 어노테이션도 정확한 개념을 알고 적절하게 사용할 수 있도록 노력하자. +이상으로 DAO와 Repository에 대해 알아봤습니다. +간단한 어노테이션이라도 정확하게 개념을 알아서 적절하게 사용할 수 있도록 노력하려고 합니다. +긴 글 읽어주셔서 감사합니다! -## 참고 +### 참고 -- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/stereotype/Repository.html -- https://stackoverflow.com/questions/8550124/what-is-the-difference-between-dao-and-repository-patterns -- https://www.baeldung.com/java-dao-vs-repository +- [스프링 공식문서](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/stereotype/Repository.html) +- [StackOverFlow](https://stackoverflow.com/questions/8550124/what-is-the-difference-between-dao-and-repository-patterns) +- [Baeldung](https://www.baeldung.com/java-dao-vs-repository) diff --git a/src/content/post/2023-11-06-Testcontainers.md b/src/content/post/2023-11-06-Testcontainers.md new file mode 100644 index 000000000..9f1c1d685 --- /dev/null +++ b/src/content/post/2023-11-06-Testcontainers.md @@ -0,0 +1,542 @@ +--- +layout: post +title: '로컬에서도 프로덕션과 유사한 환경에서 테스트할 수 없을까? : Testcontainers 도입기' +author: [5기_리오] +tags: ['Test', 'Testcontainers'] +date: '2023-11-21T20:00:00.000Z' +draft: false +image: ../teaser/testcontainers.png +--- +우아한테크코스 레벨3과 레벨4 동안 프로젝트를 진행하면서, 로컬 테스트 환경과 실제 운영 환경이 다른 기술적 어려움을 겪었습니다. +해당 어려움으로 비롯된 문제 상황과 단계적 해결 방안, 그리고 최종 해결 방안으로 도입한 `Testcontainers`에 대한 소개와 사용법을 공유하려 합니다. + +
+ +### 문제 상황 : 로컬에서는 H2, 프로덕션에서는 MySQL + +Spring Boot를 사용하여 프로젝트를 진행했습니다. 로컬 테스트 시에는 Spring Boot에 embedded 되어있는 H2를 사용했습니다. 개발 서버와 운영 서버에서는 MySQL을 사용했습니다. 그리고, Flyway를 통해 DB Schema 관리를 했습니다. + +개발을 진행하며, 사용자분들의 니즈에 맞춰 프로젝트의 요구사항들이 추가되었습니다. 또, 리팩터링을 하며 패키지를 컨텍스트별로 분리했습니다. +이러한 과정 중에 외래키와 unique 제약조건을 해제하거나 칼럼을 변경해야 하는 상황이 있었습니다.  + 이 때 H2와 MySQL의 문법이 달라서, MySQL 문법으로 작성되어있는 Flyway용 migration File 내의 sql문을 H2에서 사용하지 못했습니다. 그래서 **H2를 사용하는 로컬 profile로는 빌드가 불가능**한 문제가 있었습니다. + +>⚠️ Wix에서 만든 embedded MySQL 오픈소스를 사용하면 간단하게 의존성을 추가함으로써 테스트 시에 경량화된 MySQL을 사용할 수 있지만, +> +>1. 벤더가 변경될 시 해당 데이터베이스의 embedded 라이브러리가 없다면 같은 문제가 발생 +>2. embedded MySQL을 사용하기 위해 추가적인 소스코드 생성 필요 +>3. Window 환경에서 MySQL 8.0 이상을 지원하지 않음 +>4. 추후에 이야기 할, 다른 리소스(AWS S3)에 대한 의존성도 해결하고 싶었음 +> +>위와 같은 문제로 해당 방법으로는 문제를 해결하지 않았습니다. + +
+ +### 1차 해결 방안 : 로컬에서 Flyway를 사용하지 않는 방법 + +우선 팀에서 도출한 해결방안은 '**Flyway를 로컬에서 사용하지 말자!**'였습니다. +처음에 저는 아래와 같은 이유로 반대했습니다. +1. Flyway의 migration File이 제대로 작동하는지 로컬에서 확인할 수 없다. +2. 도메인에 변경이 있을 때마다 테스트를 위한 스키마를 다시 수정해야한다. + +그러나 다른 할 일들이 많이 남았는데도 서비스 배포까지 시간이 얼마 남지 않았고(~~하루 전이었던 건 안 비밀...~~), 해당 방법이 가장 간단하므로 적용하자는 의견에 동의하였습니다. +무사히 배포를 했지만, 추후 개발을 진행하며 우려했던 상황이 발생했습니다. +매번 테이블의 변경 사항이 있을 때마다 Flyway의 migration file에 ddl도 작성하고, 로컬 테스트 용 스키마도 매번 수정하는 공수가 추가로 들어서 더욱더 개발 리소스가 많이 들었습니다. 그리고 가장 큰 문제는 배포하기 전까지 Flyway가 제대로 작동하는지 알 수 없다는 점이었습니다. + +
+ +### 2차 해결 방안 : 벤더 별로 Flyway Migration File 각각 작성 + +Flyway를 H2와 MySQL에 동시에 사용할 수 있는 방법을 구글링하고 공식 문서를 읽어본 결과, **각 벤더사 별로 패키지를 따로 두면** 각각에 맞는 패키지에서 migration file을 읽는다는 것을 알게 됐습니다. 그래서 H2용과 MySQL용 패키지를 따로 두는 방법으로 해결했습니다. +이제 배포를 하기 전에 Flyway가 제대로 작동하는지를 H2 한정으로는 확인할 수 있게 됐습니다. 다만 아직도 H2가 아닌 MySQL 환경에서 Flyway가 제대로 작동하는지는 확신할 수 없다는 점, 그리고 ddl을 두 가지 버전으로 짜기 때문에 공수가 든다는 점이 문제로 남아있었습니다. + +
+ +### 3차 해결 방안 : Docker를 띄워볼까? + +~~ddl을 매번 두 개씩 짜다가 지친~~ 팀에서 더 좋은 방법을 협의하게 되었고, docker등을 사용하여 **컨테이너로 MySQL 환경을 로컬에 구축**하자고 의견을 냈습니다. 해당 방법은 실제 운영환경과 유사한 환경에서 테스트를 진행할 수 있게 되는 장점이 있습니다. 그러나 저를 비롯한 팀원들이 docker를 거의 사용해보지 않아서, 데모데이가 얼마 남지 않은 상황에서 학습 비용을 고려해야 한다는 의견이 있었습니다. + +
+ +### 최종 해결 방안 : Testcontainers를 사용하자 + +팀원들의 학습 비용도 줄이고(혹은 아예 없애고), 운영환경과 유사한 환경에서 테스트를 진행할 수 있는 방법을 찾기 위해 3~4일여간 구글링을 열심히 했습니다. 또, 다른 팀들의 코드와 블로그도 많이 찾아봤습니다. 그 결과, **Testcontainers**라는 새로운 기술을 찾아냈습니다. + +공식문서와 StackOverFlow를 열심히 뒤지며 사용법을 알아냈습니다. 설정 파일에서 단 두 줄, 혹은 테스트에서 클래스 하나 정도만 추가하면 (docker가 실행되고 있다는 가정하에) **자동으로 컨테이너를 띄워주면서** 테스트를 실행할 수 있었습니다. 해당 기술을 사용해서 팀원들의 학습 비용도 줄이고 더욱 실제 운영환경과 유사한 환경에서 테스트를 진행할 수 있게 되었습니다. +이제 그 혁신적인 라이브러리인 `Testcontainers`를 소개하겠습니다. + +
+ +--- +
+ +# Testcontainers + +![](https://i.imgur.com/TawF6JD.png) + +Testcontainers의 공식 사이트에서는 다음과 같이 소개하고 있습니다. + +> Testcontainers is a library that provides easy and lightweight APIs for bootstrapping local development and test dependencies with real services wrapped in Docker containers. Using Testcontainers, you can write tests that depend on the same services you use in production without mocks or in-memory services. + +즉, 도커 컨테이너로 래핑된 실제 서비스를 제공해서, 로컬 테스트 시에도 mocking이나 in-memory 서비스들을 사용하지 않고 운영환경에서 사용하는 실제 서비스에 종속되는 테스트를 작성할 수 있게 해주는 오픈 소스 라이브러리입니다. + +
+ +## Testcontainers의 장점 + +~~프로젝트의 아키텍쳐는 매우 간단해서~~, Testcontainers의 사용 효과를 극적으로 설명하기 위해 공식 사이트에서 소개하는 예시를 가져와봤습니다. + +![](https://i.imgur.com/JdtwWLL.png) + +위의 도식에서 볼 수 있듯이, 현재 `My Service`는 데이터베이스, 다른 서비스, 이벤트/메세지 브로커, AWS S3 같은 다른 클라우드 서비스에 의존성을 가지고 있습니다. 여기서 **다음과 같은 문제**들이 발생합니다. + +1. 테스트를 실행하기 전에 인프라가 가동 중이고, 원하는 상태로 미리 구성되어 있는지 확인해야합니다. +2. 데이터베이스, 이벤트/메세지 브로커 등 여러 사용자 또는 CI 파이프라인에 걸쳐 공유되는 리소스의 경우, 데이터와 구성의 변경 가능성으로 인해 테스트 시 멱등성을 보장할 수 없습니다. + +>⚠️ 멱등성이란? +> 연산을 여러 번 적용하더라도 결과가 바뀌지 않는 성질을 말합니다. + +
+ +그래서 그동안 프로젝트에서도 아래와 같이 해당 리소스들을 mocking하는 방법으로 테스트를 작성했었습니다. + +```java +class FileUploaderTest { + + @Mock + private FilePath filePath; + + @Mock + private FileUrlMaker fileUrlMaker; + + @InjectMocks + private FileUploader fileUploader; + + @Test + void 파일의_URL을_반환한다() { + // given + UUID randomUUID = UUID.randomUUID(); + String baseUrl = "https://example.com/files/"; + String expectedFileUrl = baseUrl + randomUUID + ".jpg"; + MultipartFile multipartFile = mock(MultipartFile.class); + given(multipartFile.getContentType()).willReturn(IMAGE.contentType()); + given(fileUrlMaker.make(any())).willReturn(expectedFileUrl); + + // when + String url = fileUploader.upload(multipartFile); + + // then + assertThat(url).isEqualTo(expectedFileUrl); + } +} +``` + +프로젝트 내에서 생성된 파일이 저장소에 업로드 된 후 그 주소를 반환하는 기능에 대한 테스트입니다. 기존에는 서버에 저장을 하다가 AWS S3로 변경이 되었는데, 이 경우 모두 로컬에서 테스트할 적절한 방법이 없어서 위와 같이 관련 클래스들을 전부 mocking할 수 밖에 없었습니다. + +
+ +![](https://i.imgur.com/6gN7hWR.png) + +그러나 위처럼 Testcontainers를 사용하면, 서비스가 의존하는 리소스들을 컨테이너로 래핑하여 제공해줍니다. 이렇게 되면 위에서 이야기했던 실제 리소스에 의존하여 테스트시 정확성이 보장되지 않는 문제들을 해결할 수 있고, 반복적인 테스트가 가능해집니다. + +
+ +여기까지 읽다보면 한 가지 의문이 들 수도 있습니다. +>그냥 Docker나 Docker Compose 쓰면 안 되나? + +물론 Docker와 Testcontainers 모두 컨테이너를 사용하여 리소스를 래핑하는 것은 동일합니다. 그러나 Testcontainers를 사용하면 컨테이너의 설정과 사용이 혁신적으로 편리해집니다. + +이를 설명하기 위해 위에서 언급한 장점들을 포함하여, **Testcontainers를 사용하여 얻는 장점들**을 아래에 정리해보겠습니다. + +#### 1. 격리된 인프라 제공 + 통합 테스트 시에 사용될 인프라 리소스들을 미리 준비하지 않아도 됩니다. Testcontainers를 사용하면 테스트를 실행하기 전에 자동으로 해당 리소스들을 제공합니다. 각 파이프라인이 격리된 서비스 집합으로 실행되므로 여러 빌드 파이프라인이 병렬로 실행되는 경우에도 테스트 데이터 오염이 발생하지 않습니다. +#### 2. 로컬 및 CI 환경 모두에서 일관된 테스트 + IDE에서 바로 통합 테스트를 실행할 수 있어서, 변경 사항을 push하고 CI 파이프라인이 완료될 때까지 기다릴 필요가 없습니다. +#### 3. 대기 전략을 사용한 안정적인 테스트 설정 + 테스트를 실행하기 전에 컨테이너를 초기화해야합니다. Testcontainers를 사용하면 컨테이너와 그 안에 있는 어플리케이션이 완전히 초기화되었는지 확인하는 몇 가지 대기 전략을 제공합니다. 또, 모듈을 사용하여 해당 전략들을 직접 구현하거나 복합 전략을 생성할 수도 있습니다. +#### 4. 고급 네트워킹 기능 + Testcontainers를 사용하면 컨테이너의 포트를 사용 가능한 임의의 포트에 매핑하여 테스트가 해당 서비스에 안정적으로 연결되도록 해줍니다. 심지어 네트워크를 생성하고 여러 컨테이너를 함께 연결하여 각 컨테이너가 서로 통신하도록 할 수도 있습니다. +#### 5. 자동 초기화 + 테스트 실행이 완료된 후, 생성된 모든 리소스(컨테이너, 볼륨, 네트워크 등)를 `Ryuk Sidecar 컨테이너`를 사용하여 자동으로 제거합니다. 필요한 컨테이너를 시작하는 동안 생성된 리소스에 일련의 라벨을 붙이고, Ryuk은 해당 라벨을 매칭하여 자동으로 리소스 정리를 수행해줍니다. 그래서 테스트가 비정상적으로 종료되더라도 안정적으로 작동합니다. + +즉, Docker와 Docker Compose를 사용하여 직접 명령어를 사용하거나 서비스 종속성을 초기화하는 등의 행위를 하려면 Docker에 대한 내부 지식과 컨테이너에서 특정 기술을 실행하는 방법에 대한 지식이 필요합니다. 이러한 부분에 대한 지식이 없다면, 포트가 충돌하거나 테스트 시작 시에 컨테이너가 초기화되지 않는 문제, 또는 상호작용할 준비가 되지 않는 등의 문제가 발생할 수 있습니다. +Testcontainers를 사용하면 **내부에서 이러한 설정을 전부 지원**해주므로 개발자는 API를 통해 이러한 지식 없이도 컨테이너 기반의 테스트를 사용할 수 있게 됩니다. + +![](https://i.imgur.com/Atw4AD9.jpg) +Testcontainers가 지원하는 DB 및 인프라 리소스들 목록입니다. 웬만한 건 다 있네요! + +Docker나 Docker Compose에 대한 학습 비용을 절감하면서도 컨테이너 기반 테스트를 사용할 수 있는 점이 저희 팀의 문제를 깔끔하게 해결하기 때문에 Testcontainers를 사용하기로 했습니다. +또, 프로젝트가 커져가면서 인프라 리소스가 추가될 가능성이 매우 높았기 때문에 해당 리소스에 대한 테스트를 위해서도 Testcontainers를 사용하기로 했습니다. + +--- + +## Testcontainers 사용법 + +MySQL 컨테이너를 띄우는 방법을 예시로 사용법을 설명하겠습니다. +다른 리소스(Kafka 등)에 대한 사용법은 참고 자료에 잇는 공식 문서에 자세하게 설명이 되어있습니다. MySQL 컨테이너를 띄우는 방법과 매우 유사하므로, 어렵지 않게 사용하실 수 있습니다. + +우선, 의존성을 추가해줍니다. + +``` +testImplementation "org.testcontainers:testcontainers:1.19.1" +testImplementation 'org.testcontainers:junit-jupiter:1.19.1' +testImplementation 'org.testcontainers:mysql' +``` +JUnit과 MySQL에 대한 모듈 의존성도 추가해줍니다. (이유는 to be continue...) + +
+ +### 기본적인 사용 방법 + +기존에 존재하는 테스트에 대해서 MySQL 컨테이너를 띄워서 실행해보겠습니다. +우선, 기존의 테스트 코드입니다. + +```java +@ServiceTest +class MenuGroupServiceTest { + + @Autowired + private MenuGroupRepository menuGroupRepository; + + @Autowired + private MenuGroupService menuGroupService; + + @Test + void 메뉴_그룹을_등록한다() { + // ... + } + + @Test + void 메뉴_그룹들을_조회한다() { + // ... + } +} + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest +@Transactional +public @interface ServiceTest { +} +``` +테스트를 실행하면, H2에서 해당 테스트를 진행하고 있음을 확인할 수 있습니다. + +![](https://i.imgur.com/k4qtysf.png) + +이제, MySQL 컨테이너를 띄우고 해당 컨테이너에서 테스트가 진행되도록 하겠습니다. +세 가지 방법이 존재하고, 세 방법 모두 "아주".repeat(Integer.MAX_VALUE) 간단합니다. + +
+ +#### 첫 번째 방법 : 컨테이너에 대한 인스턴스 생성 +
+테스트 클래스 내에서 `Testcontainers` 모듈이 제공하는 MySQLContainer 인스턴스를 생성합니다. +그 후, @BeforeEach, @AfterEach 등을 활용해서 해당 컨테이너에 대한 조작을 합니다. + +```java +@ServiceTest +class MenuGroupServiceTest { + + @Autowired + private MenuGroupRepository menuGroupRepository; + + @Autowired + private MenuGroupService menuGroupService; + + private MySQLContainer mySQLContainer = new MySQLContainer("mysql:8"); + + @BeforeEach + void setUp() { + mySQLContainer.start(); + } + + @AfterEach + void tearDown() { + mySQLContainer.stop(); + } + + @Test + void 메뉴_그룹을_등록한다() { + // ... + } + + @Test + void 메뉴_그룹들을_조회한다() { + // ... + } +} +``` +원래 Testcontainers 모듈에는 Container라는 인터페이스와 구현체인 GenericContainer가 존재합니다. MySQL에 대한 의존성을 위에서 추가해줬기 때문에 (~~복선 ㄷㄷ~~) GenericContainer를 상속받은 MySQLContainer를 사용할 수 있게 되었고, 이를 통해 추가적인 설정없이 바로 MySQL 컨테이너를 띄울 수 있게 되었습니다. + +![](https://i.imgur.com/vHO4z6H.png) + +![](https://i.imgur.com/KxxHqFc.png) +![](https://i.imgur.com/JkbeWaZ.png) +![](https://i.imgur.com/DYZ4N2J.png) + +이제 테스트를 실행해보면, MySQL 컨테이너가 띄워지는 것을 확인할 수 있습니다. +(만약 Driver를 찾지 못하는 예외가 발생한다면 MySQL에 대한 의존성을 추가해주세요!) +``` +implementation 'com.mysql:mysql-connector-j' +``` + +![](https://i.imgur.com/BrRsAzK.gif) +gif 파일입니다! 컨테이너가 올라가고 내려가는 시간이 오래 걸려서 편집을 했습니다. + +![](https://i.imgur.com/CijiL8B.png) +![](https://i.imgur.com/XSfP5MZ.png) +![](https://i.imgur.com/miuibAl.png) +글자가 너무 작아서, 아래에 설명을 적었습니다! + +보시듯이, `RYUK Sidecar 컨테이너`가 먼저 띄워지고, 첫번째 테스트에 대한 컨테이너가 띄워집니다. (Testcontainers의 장점에서 설명했습니다!) +첫번째 테스트가 종료된 후, `RYUK Sidecar 컨테이너`는 그대로 있고 그 다음 테스트에 대한 컨테이너가 띄워지는 것을 볼 수 있습니다. (눈썰미가 좋으시다면, 위의 이미지의 스크롤 크기를 통해 첫번째 테스트 시에만 Ryuk 컨테이너가 실행됐음을 확인하실 수 있습니다. 저는 몰랐습니다.) + +
+ +#### 두 번째 방법 : JUnit5의 @ExtendWith 사용 +
+JUnit5의 @ExtendWith을 통해서, 테스트 인스턴스의 생명주기를 Intercept하는 Extension을 사용하는 방법도 있습니다. 설명은 거창하지만, 어노테이션 두 개만 추가하면 됩니다. + +```java +@Testcontainers +@ServiceTest +class MenuGroupServiceTest { + + @Autowired + private MenuGroupRepository menuGroupRepository; + + @Autowired + private MenuGroupService menuGroupService; + + @Container + private MySQLContainer mySQLContainer = new MySQLContainer("mysql:8"); + + @Test + void 메뉴_그룹을_등록한다() { + // ... + } + + @Test + void 메뉴_그룹들을_조회한다() { + // ... + } +} +``` +@Testcontainers와 @Container 모두 위에서 추가해준 Testcontainers JUnit 모듈에서 제공하는 기능입니다. (~~복선222 ㄷㄷ~~) + +어떻게 어노테이션 두 개만 달면 자동으로 컨테이너가 올라갈까요? +이제, @Testcontainers에 대해 살펴보겠습니다. +```java +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(TestcontainersExtension.class) +@Inherited +public @interface Testcontainers { + /** + * Whether tests should be disabled (rather than failing) when Docker is not available. Defaults to + * {@code false}. + * @return if the tests should be disabled when Docker is not available + */ + boolean disabledWithoutDocker() default false; + + /** + * Whether containers should start in parallel. Defaults to {@code false}. + * @return if the containers should start in parallel + */ + boolean parallel() default false; +} + +``` + +@ExtendWith(TestcontainersExtension.class)가 있는 것을 보니, `TestcontainersExtension`이라는 Extension이 테스트 인스턴스의 Life-Cycle을 intercept하는 것을 짐작할 수 있습니다. + +```java +public class TestcontainersExtension +implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback, ExecutionCondition { + + // ... + + @Override + public void beforeAll(ExtensionContext context) { + + // ... + + List sharedContainersStoreAdapters = findSharedContainers(testClass); + + // ... + } + + @Override + public void afterAll(ExtensionContext context) { + // ... + } + + @Override + public void beforeEach(final ExtensionContext context) { + + // ... + + List restartContainers = collectParentTestInstances(context) + .parallelStream() + .flatMap(this::findRestartContainers) + .collect(Collectors.toList()); + + // ... + + } + + @Override + public void afterEach(ExtensionContext context) { + // ... + } + + private List findSharedContainers(Class testClass) { + return ReflectionSupport + .findFields(testClass, isSharedContainer(), HierarchyTraversalMode.TOP_DOWN) + .stream() + .map(f -> getContainerInstance(null, f)) + .collect(Collectors.toList()); + } + + private Predicate isSharedContainer() { + return isContainer().and(ModifierSupport::isStatic); + } + + private Stream findRestartContainers(Object testInstance) { + return ReflectionSupport + .findFields(testInstance.getClass(), isRestartContainer(), HierarchyTraversalMode.TOP_DOWN) + .stream() + .map(f -> getContainerInstance(testInstance, f)); + } + + private Predicate isRestartContainer() { + return isContainer().and(ModifierSupport::isNotStatic); + } + + private static Predicate isContainer() { + return field -> { + boolean isAnnotatedWithContainer = AnnotationSupport.isAnnotated(field, Container.class); + + // ... + + }; + } +} +``` + +예상대로, beforeAll과 beforeEach를 오버라이딩하면서 @Container가 붙은 정적/클래스 인스턴스로 컨테이너를 띄우고 있습니다. 그래서 @BeforeEach, @AfterEach가 붙은 setUp(), tearDown() 메서드 없이도 컨테이너가 자동으로 띄워지고, 내려집니다. + +
+ +#### 두 가지 방법의 공통된 장점 +
+우선 장점으로는, 두 방법 모두 인스턴스를 직접 생성하여 사용하므로 조금 더 정교한 customizing이 가능합니다. + +```java +private MySQLContainer mySQLContainer = new MySQLContainer("mysql:8"); + +{ + mySQLContainer.withUsername("리오") + .withPassword("짱짱") + .withDatabaseName("멋쟁이") + .withConfigurationOverride("귀요미"); +} +``` + +MySQLContainer의 Super Class인 JdbcDatabaseContainer와 GenericContainer는 훨씬 더 많은 메서드를 가지고 있습니다. 이러한 메서드들을 사용해서 컨테이너를 customizing하거나 원하는 정보를 얻을 수 있습니다. + +
+ +#### 두 가지 방법의 공통된 단점 및 해결방법 +
+두 가지 방법 모두 테스트 메서드마다 컨테이너를 새로 띄웁니다. 각 컨테이너가 올라가고 내려가는 시간이 꽤 길기 때문에, 테스트의 수가 많아진다면 전체 테스트의 수행 시간이 굉장히 오래 걸리게 됩니다. + +이를 해결하기 위해서는 **하나의 컨테이너 인스턴스만 사용하도록** 하면 됩니다. +```java +class WithContainerTest { + + protected static MySQLContainer mySQLContainer = new MySQLContainer("mysql:8"); + + static { + mySQLContainer.start(); + } +} +``` + +```java +@Testcontainers +class WithContainerTest { + + @Container + protected static MySQLContainer mySQLContainer = new MySQLContainer("mysql:8"); +} +``` +```java +@SpringBootTest +class SomeTest extends WithContainerTest { + // ... +} +``` +이렇게 클래스를 하나 생성한 후 MySQLContainer의 인스턴스를 static 메모리에 올리면(싱글턴 패턴이죠!), 이후 해당 클래스를 상속하는 테스트들은 하나의 MySQLContainer 인스턴스만 사용할 수 있게 되...**지 않습니다!!! (주의!!!)** + +정확히 말하면, **두 번째 방법인 @Testcontainers를 사용하는 경우에 해당 방법이 작동하지 않습니다.** JUnit의 Life-Cycle 관리는 클래스 단위로 이루어지기 때문입니다. 그래서, 다른 테스트 클래스가 실행되면 또 다른 Life-Cycle이 돌기 때문에 해당 static 컨테이너를 공유하지 못하고 새로 띄우게 됩니다. +다만, 한 클래스 내의 메서드들은 하나의 컨테이너를 공유할 수 있습니다. 위의 TextcontainersExtension의 코드에서 beforeAll() 메서드 내부에서도 findSharedContainer()로 static 인스턴스를 찾는 로직이 보이네요. +[공식 문서](https://testcontainers.com/guides/testcontainers-container-lifecycle/)에서도 'common misconfiguration'이라고 설명하며, Extension을 이용한 방법으로는 클래스 간 컨테이너 공유가 불가능하다고 합니다. + +그래서, 전체 테스트에서 하나의 컨테이너만 사용하고 싶다면 처음 소개드린 대로 싱글턴 패턴을 사용해야 합니다! (**~~그런데 과연 이 방법만 있을까요? 후훗...~~**) + +![](https://i.imgur.com/qZsgssD.gif) +보시듯이 하나의 컨테이너만 띄워지고 이 컨테이너에서 모든 테스트가 실행되는 것을 확인할 수 있습니다. + +추가적으로, 하나의 컨테이너를 사용하게 되면서 테스트 격리를 해야하는 상황이 있을 수 있습니다. 기존에 했던 방식대로 테이블들을 truncate 해주시면 됩니다. 저는 개인적으로 [이 블로그](https://kong-dev.tistory.com/248)에서 추천하는 방식이 가장 좋았습니다. + +
+ +#### 세 번째 방법 : Datasource URL 설정 +
+ +프로젝트의 상황을 조금 더 고민해본 결과, 위의 두 방법보다도 훨씬 간단한 방법을 직접 찾아낼 수 있었습니다. 간단하기도 하고, Testcontainers의 라이브러리 사용을 위해 기존 테스트 코드가 오염되는 것이 싫으신 분들 (~~혹은 코드 몇 줄 더 치기 귀찮은 분들~~)을 위한 방법일 수 있습니다. + + +> ⚠️ 이 방법은 사용하시는 상황에 따라 적절하지 않은 방법일 수 있습니다. +> 이후에 설명하는 상황과 부합하는 경우에만 사용하세요. + +현재 저희 프로젝트는 테스트 시 MySQL 컨테이너 하나만 띄우면 되는 상황입니다. 그래서 **프로파일 설정 파일의 Datasource만 띄워지는 컨테이너로 설정하면 되지 않을까?** 라는 생각을 했습니다. +Spring에서는 (스코프를 재설정하지 않는 이상) **DataSource를 빈으로 등록**하고, 여러 곳에서 참조할 때 재사용하는 것을 알고 있었기 때문에, 가능할 것으로 생각했습니다. + +찾아보니 Datasource를 다음과 같이 Testcontainers가 띄워주는 MySQL 컨테이너로 설정하는 방법이 있었습니다. + +``` +spring: + datasource: + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + url: jdbc:tc:mysql:8://test + username: root + password: test +``` +(데이터베이스 이름과 username, password는 원하는대로 설정해도 됩니다!) + +**결과는...!** + +![](https://i.imgur.com/ymMrd9F.gif) +(**성공적이었습니다!** 315(+ɑ)에 달하는 커버리지 93%(Line 기준)의 테스트도 1분 안에 끝납니다!) + +물론 위의 두 방법처럼 테스트 격리를 위해 truncate가 필요하긴 하지만, 코드로 인스턴스를 생성할 필요없이 간단하게 Testcontainers를 사용할 수 있었습니다. + +올바른 방법인지 확인해보려고 공식문서를 열심히 뒤져서 [JDBC 모듈 관련 문서](https://java.testcontainers.org/modules/databases/jdbc/)를 찾아냈습니다. Testcontainers에서 지원하는 기능이 맞았고, 적절한 driver만 가지고 있다면 다른 벤더에도 적용할 수 있었습니다. (설명드린 첫번쨰 방법과 세번째 방법을 동시에 해야한다고 알려주는 다른 블로그 글들이 많으니 주의해주세요!) + +--- +### 마치며 +Testcontainers를 사용하여 로컬에서도 실제 운영 환경과 유사한 환경에서 테스트를 수행하는 방법에 대해서 알아봤습니다. +개인적으로는 팀의 문제를 단계별로 해결해나가며 최선의 방법을 찾아냈고, 잘못 알려져있는 사용법들을 올바른 방법으로 소개해드릴 수 있었던 뜻깊은 경험이었습니다. +긴 글 읽어주셔서 감사합니다! + +
+ +#### 참고 자료 + +- [Testcontainers 공식 사이트](https://testcontainers.com/) +- [Testcontainers for Java 공식 문서](https://java.testcontainers.org/) +- [Testcontainers로 MariaDB 통합 테스트하기 - 최범균 님](https://www.youtube.com/watch?v=eZbLAD2yUfE) +- [TestContainer로 멱등성있는 integration test 환경 구축하기](https://medium.com/riiid-teamblog-kr/testcontainer-%EB%A1%9C-%EB%A9%B1%EB%93%B1%EC%84%B1%EC%9E%88%EB%8A%94-integration-test-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-4a6287551a31) diff --git a/src/content/teaser/testcontainers.png b/src/content/teaser/testcontainers.png new file mode 100644 index 000000000..4ea7886e5 Binary files /dev/null and b/src/content/teaser/testcontainers.png differ