Rust Axum 테스팅 자동화 커리큘럼 학습 이야기
최종 소스코드 (깃허브 링크)
기존 지식
웹 풀스택 개발 경험이 좀 있고 AWS EC2 배포 경험, 도커 활용 Jenkins CI/CD 구축 등 다양하게 해봄
python, javascript, java 언어 공부했고
django, fastapi, expressjs, reactjs, springboot 사용해봤음
1. 클론 코딩
전반적인 백엔드 API 구조는 알고 있었기 때문에 아래 영상을 보며 rust와 axum에 대해서 감을 잡음
2. 전체적으로 공부 & 활용한 것
Rust, AXUM, design pattern (handler - usecase - repository)
SQLx, Postgres, CTE활용 쿼리 최적화
Docker, Testing (unit, AAA, TDD), 스크립트 활용 자동화 (커버리지 결과 정리)
S3 활용 image 관리, Auth Middleware
기본적으로 테스팅하는 방법에 대해서 관심이 있었고, 마침 관련 아티클을 찾아서
따라하면서 공부해봐야겠다고 생각해서 아래 내용을 참고해서 개발을 시작함
Step-by-Step Guide to Test Driven Development (TDD) in Rust Axum
관련해서 테스트 데이터베이스를 테스트할때만 만들어서 테스팅하는 방식을 알게 되어
그것도 자동화하면 완전 테스팅 자동화가 될 것 같다는 생각을 하고 shell 스크립트 구현
+ 추가)
블로그에 글 정리하면서 알게 된 postgresql 쿼리 성능 모니터링 도구
pg_stat_statements -> query 실행 횟수, 소요시간, 평균시간을 확인할 수 있음
3. 공부 방법
가장 중요하다고 생각했던 건 보지 않고 로직을 구상해서 내가 직접 구현해보는 것
처음엔 알던 기능을 참고해서 타이핑하고 안보고 해당 내용을 기억해서 타이핑 하는것을 계속해서 연습함
sql 쿼리도 처음엔 gpt가 준대로 따라쳤지만 (정말 안되는 경우 아니면 복붙안함) WITH문 내용을 직접 생각해서 로직대로 타이핑해보는 버릇을 들임. 이렇게 학습하니 나중엔 GPT한테 안물어보고 기계적으로 타이핑하기 시작했음 (필요한 로직들은 단위테스팅에서 미리 설계가 되어있음)
직접 생각해서 치는거랑 보고 치는거랑은 어디서 잘못된 코드를 작성했는지, 어느 부분이 중요한지를 학습하는데 생각보다 큰 영향을 미침.
Axum 활용해서 API 개발 들어갈 때는 ChatGPT o1-mini 활용하여 지속적으로 개발 방향과 성능 개선 방향에 대한 질문함
답변 확인 후 개발 방향을 선택하여 학습 (DI를 최대한 줄이고 CTE를 사용하게 된 계기)
1. Rust
가장 먼저는 Rust 언어 자체에 관심이 있었기에 먼저 Rust Exercise를 통해 실습 위주의 언어 공부 (언어 특징, 개념잡기)
Easy Rust는 학습방식이 안맞아서 중간에 그만둠 (동영상 강의도 있고 언어 기본기를 공부하기엔 좋은 듯)
2. Axum
tokio-rs/Axum 레포에 있는 examples를 통해 기본 기술스택을 정함
mongodb는 한번 해봤었기 때문에, sqlx, postgresql를 사용
3. Design Pattern
기본적으로 클론코딩 영상에서 사용하는 디자인 패턴을 적용하여 개발하기 시작 (handler, usecase, repository)
스프링부트에서도 controller / service / repository와 같은 디자인 패턴을 따르기 때문에 무리없이 적용했고,
오히려 이번에 단위 테스팅하면서 각 레이어의 역할에 대해서 좀 더 깊이 이해할 수 있었음.
handler에서는 요청하는 데이터에 대한 validation 검증 / response 처리 (statuscode, body 등)
usecase는 비즈니스 로직 (하지만 대부분 쿼리를 CTE로 작성하여 repository가 대부분 처리함)
handler dto를 entity 객체로 변환하는 정도의 역할만 부여하고 나중엔 테스트코드조차 작성하지 않음.
repository - 각종 비즈니스 로직을 모두 한 query로 처리 한 쿼리 내에서 검증과 삽입, 수정, 삭제를 처리
이렇게 한 이유가 데이터베이스 네트워크 오버헤드를 줄이기 위해서 적용해봄.
최종적으로 폴더 구조는
데이터베이스 관련
- erd를 사이트에 자꾸 왔다갔다 하면서 보면 귀찮아서 넣어놓으니까 생각보다 편했음
- 도커로 테스트 데이터베이스를 띄우기 위한 설정파일들 init.sql (테이블 초기화, 테스트 데이터 삽입)
- postgresql.conf: 나중에 쿼리 성능 모니터링을 위해 도입한 pg_stat_statements 설정용
app 관련
우선은 aws, jwt, database와 관련 설정 파일을 관리하는 config 폴더
주 기능들을 개발하는 domain폴더, 다양한 도메인에 사용되는 것들은 global 폴더에 모아둠.
사용자 인증을 위한 middleware
tests 폴더는 gpt가 폴더 구조를 저렇게 잡은거고 mocking하는 파일과 그것을 가져다 사용하는 식으로 테스팅 하는 듯 (해당 파일에 단위 테스트 코드를 작성했기 때문에 따로 사용해보진 않음)
domain의 기능 별
기본적으로 CRUD를 기준으로 모든 기능들을 파일로 작성하고 CRUD 내에서도 repository를 별도로 생성함 (SaveBookRepo, GetBookRepo)
개발을 다 하고나서 ChatGPT는 state로 repository, usecase를 관리해서 필요한곳에서 가져다 사용한 방식도 추천함
mod.rs에서 통합하여 repository 하나만 생성하고 그것을 state가 공유하는 방식으로도 사용
관련해서 GPT 선생의 설명은 여기서 확인할 수 있음 (CTE관련 설명도 있음)
단위 테스트코드를 작성하게 되면 한 파일의 길이가 평균적으로 150줄정도 나오기 때문에 그냥 분리해놓았고
만약에 리팩토링 한다면 mod.rs에서 하나의 struct를 만들고 각 파일에 있는 함수를 연결하는 식으로 활용할 듯
dto는 데이터 전송단에서 주고받는 것들, entity는 db 객체에 대한 정의, route는 최종적으로 handler에 있는 route들을 모아서 적용해줌
main.rs부터 타고들어가보면 확인할 수 있는데, pool을 하나 생성한 뒤 그것을 router 별로 repository에 참조자로 전달하고, 최종적으로 repository에서 clone()하여 자원을 공유함
전체적인 폴더구조는 저런식으로 잡아놓고 개발을 시작함
4. SQLx, PostgreSQL, CTE
데이터베이스는 PostgreSQL을 사용함. 이유는 딱히 없고 그냥 제일 좋다고 해서? 그리고 SpringBoot 쓰면 배포할때 DB는 저걸로 하길래 그냥 씀.
처음에 어떻게 설정을 할지, 어떻게 커넥션 pool을 관리할지 감 잡는거에서 되게 많이 고민한 듯.
원래는 pool을 main.rs에서 clone하고 그걸 계속 넘기는 식으로 repository로 전달했는데 참조자로 넘기고 맨 마지막에 pool.clone()하라고 해서 그렇게 함 근데 결론적으로는 별차이 없을듯
DB통신 횟수를 줄이기 위해 repository 부분 개발할 때 GPT한테 특정 로직을 설명하고 한 sql로 처리하는 방법이 있는지 물어보니 WITH문으로 쿼리작성하는 방식을 알려주길래 처음엔 그냥 따라서 쳐보고 안되는건 분리했었음. 근데 계속 사용해보니 안되는게 아니었고 문법을 잘 이해 못한거여서 나중엔 결국 한 sql로 처리하도록 다시 작업, 이렇게 하니 usecase의 비즈니스 로직을 repository가 다 먹어버리는 상황이 발생.
원래는 usecase가 비즈니스 로직을 처리해서 적절한 오류 처리를 해주는것이 필요한데, repository가 그 로직들을 모두 담당하게 되면서 비즈니스 로직까지 수행함.
그 결과로 최종 CTE의 값만 반환하면 예외 처리를 다양하게 할 수 없어졌고 캐싱된 결과들에 대해서 SELECT를 통해 중간중간 예외상황 체크한 결과들을 반환하도록 함
InsertResult, UpdateResult, DeleteResult를 만들어 그 구조체로 매핑되도록 하였고 그 내용에는
is_exist -> 존재 여부 체크
is_authorized -> 권한 체크
is_duplicated -> 중복 여부 체크
id -> 생성 시 반환받는 id값
결론적으로 SQL은 이런 형태로 작성함.
WITH BaseExists AS (
SELECT id, book_id
FROM tb_base_category
WHERE id = $2
),
AuthorityCheck AS (
SELECT be.id AS base_id
FROM BaseExists AS be
JOIN tb_book AS b ON be.book_id = b.id
JOIN tb_user_book_role AS br ON br.book_id = b.id
WHERE br.user_id = $1 AND br.role != 'viewer'
),
DuplicateCheck AS (
SELECT EXISTS (
SELECT 1
FROM AuthorityCheck AS a
JOIN tb_sub_category AS s ON a.base_id = s.base_id
WHERE s.name = $3
) AS is_duplicated
),
InsertSubCategory AS (
INSERT INTO tb_sub_category (base_id, name)
SELECT base_id, $3
FROM AuthorityCheck
WHERE (SELECT is_duplicated FROM DuplicateCheck) = false
RETURNING id
)
SELECT
EXISTS (SELECT 1 FROM BaseExists) AS is_exist,
EXISTS (SELECT 1 FROM AuthorityCheck) AS is_authorized,
(SELECT is_duplicated FROM DuplicateCheck),
(SELECT id FROM InsertSubCategory);
위의 쿼리를 활용한 repository의 한 function
#[derive(Debug, sqlx::FromRow)]
struct InsertResult {
id: Option<i32>,
is_exist: bool,
is_authorized: bool,
is_duplicated: bool,
}
pub async fn save_sub_category(
pool: &PgPool,
user_id: i32,
sub_category: SubCategory,
) -> Result<i32, Box<CustomError>> {
let result = sqlx::query_as::<_, InsertResult>(
// 위의 쿼리
)
.bind(user_id)
.bind(sub_category.get_base_id())
.bind(sub_category.get_name())
.fetch_one(pool)
.await
.map_err(|e| {
let err_msg = format!("Error(SaveSubCategory): {:?}", &e);
tracing::error!(err_msg);
let err = match e {
sqlx::Error::Database(_) => CustomError::DatabaseError(e),
_ => CustomError::Unexpected(e.into()),
};
Box::new(err)
})?;
// 결과에 따른 에러처리
if !result.is_exist {
return Err(Box::new(CustomError::NotFound("BaseCategory".to_string())));
} else if !result.is_authorized {
return Err(Box::new(CustomError::Unauthorized("BookRole".to_string())));
} else if result.is_duplicated {
return Err(Box::new(CustomError::Duplicated("SubCategory".to_string())));
}
Ok(result.id.unwrap())
권한 체크같이 중복되는 SQL문이 있긴 했는데 상황마다 조금씩 달랐기 때문에 굳이 통합하지는 않음. 그냥 치는거랑 함수 연결해서 쓰는거랑 큰 차이가 없을 것 같았음 문자열 내에 바꿔끼는것도 귀찮고
결론적으로 이 방법이 캐싱을 적용하기에는 불리하다고 생각을 하고 있긴 하지만 db가 한 쿼리를 바로 실행하기 때문에 lock에서 안전해지는 것과 실행계획 최적화와 같은 DB자체 기능인 쿼리 최적화를 통한 성능 향상을 노림. 통신 횟수 자체도 비즈니스 로직이 복잡해지면 3번~4번 왔다갔다 할 것을 한 번으로 줄였고 로직의 심플함과 전체 구조나 단일 기능이 크게 바뀌는 상황은 고려하지 않았기 때문에 적용.
5. 테스팅
처음에는 given-when-then 패턴으로 테스트를 진행하려고 함. 근데 테스트 코드 작성법이 익숙하지 않았고
위에서 참고한 rust axum TDD 문서도 결론적으로 내가 원하는 진짜 unit 단위의 테스팅이 아니었음.
그래서 gpt센세한테 가서 repository는 어떻게 테스트를 수행해야되는지 물어보고 AAA패턴이라는 것을 알게 됨.
사실 지금에서야 생각해보면 given-when-then이랑 뭔 차이가 있나 싶지만, 되게 직관적이라고 느꼈음
Arange (데이터, 환경 준비) - Act (실행) - Assert (검증)으로 이루어진 테스팅 방법인데, 이걸 도입하고나서 테스트 코드를 어떻게 분리하여 작업해야될지가 좀 감이 잡혔음.
repository에서 가계부에 기록을 삽입하는 로직을 단위테스트한다고 했을 때 테스트해야 할 사항은 크게 다음과 같음
1. 데이터베이스 연결 상태 확인,
2. 적절한 데이터가 들어와 가계부에 기록이 정상적으로 삽입이 되는 것
3. 권한이 없는 유저가 특정 가계부에 기록할 경우 에러 반환하도록 하는 것
4. 없는 가계부에 기록하려고 할 경우
5. (여기선 없었지만 unique필드가 있다면,) 중복이 발생하는 경우
사실상 구현할 비즈니스 로직에 대한 내용들을 먼저 고민해서 작성해놓고 해당 테스트를 통과하도록 기능을 구현하는 방향으로 감.
처음에는 어떤 것을 테스트해야 될지 잘 몰랐지만 테스트를 계속 작성하다보니 결국 저 위에서 크게 벗어나지 않았던 것 같음
Arange에서 필요한 데이터 준비하고 만약에 권한같은게 필요한 경우에는 init.sql에 미리 테스트용 데이터를 삽입해놓고 수행함
Act에서는 주로 구현할 함수를 Arange에서 준비한 데이터를 넘겨서 호출
Assert에서 기대한 결과가 일치하는지 검증하는 식으로 구현한다.
그래서 그걸 구현하면
async fn check_save_success() {
// Arrange
let pool = create_connection_pool().await;
let user_id = 1;
let book_id = 1;
let base_category = BaseCategory::new(
1,
book_id,
true,
true,
"서브카테고리용".to_string(),
"FF0012".to_string(),
);
let base_id = save_base_category(&pool, user_id, base_category)
.await
.unwrap();
let sub_category = SubCategory::new(base_id, "테스트 서브 카테고리".to_string());
// Act
let result = save_sub_category(&pool, user_id, sub_category).await;
assert!(result.as_ref().map_err(|e| println!("{:?}", e)).is_ok());
let result = result.unwrap();
// Assert
let row = sqlx::query_as::<_, SubCategory>("SELECT * FROM tb_sub_category WHERE id = $1")
.bind(result)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(result, row.get_id())
}
테스팅 함수 하나는 이런 형태로 작성. 이걸 기능별로 무한반복해서 작성하면되고,
하다보면 기계적으로 테스트코드를 작성하고 있는 나 자신을 발견하게 됨. 기능 구현하고나서 테스트 통과하면 쾌감이 쩐다. (처음엔)
원래는 usecase로 넘기지만 레포에서 지금 비즈니스 로직을 다 검증하도록 해놓아서 (중복 검사, 권한 검사같은)
usecase는 테스트할 게 거의 없었고, handler는 테스트 할 것이 응답 코드가 적절하게 오는지, 응답 데이터에는 적절한 데이터가 들어가있는지 확인한다.
근데 repository나 usecase를 실제 구현한 객체로 사용하게 되면 DB에도 반영되고 그렇게 되므로 예상되는 응답에 대해서 mocking한 객체를 별도로 구현하여 어떤 데이터가 들어왔을 때 어떤 식으로 응답을 해야되는지 arange에서 구현해놓고 그 mock 객체를 넘기는 방식으로 테스트를 수행한다. 이렇게 되면 db 통신으로 인한 오버헤드도 줄고 결과에 대해서 빠르게 반환해주기 때문에 테스트 속도에도 이점을 가질 수 있다.
6. 테스팅 자동화
gpt랑 참 많은 대화를 하고 issue도 많이 발생했던 파트
가장 최초에는 데이터베이스를 Docker로 띄웠고, 기존에 사용하던 compose 파일에 db volume이 연결되어 있어서
테스트 환경에서는 부적절했음. 그래서 volume을 연결 해제해서 컨테이너가 내려가면 테이블이나 데이터가 초기화되도록 하였고,
jwt, aws s3 같은 것을 테스트하기 위해 필요한 env들을 별도로 적용하려고 해도 잘 적용이 안돼서 docker-compose 파일을 분리함
테스트용으로 docker-compose.test.yml을 따로 생성해서 environment를 직접 넣어놓고 테스팅 진행
version: "3"
name: "sosiny-tester"
services:
db:
image: postgres:latest
environment:
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test1234
- POSTGRES_DB=test_db
ports:
- "5432:5432"
volumes:
- ./db/init/:/docker-entrypoint-initdb.d/
- ./db/postgresql.conf:/etc/postgresql/postgresql.conf
command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 6s
retries: 5
tester:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://test:test1234@db:5432/test_db
- JWT_ACCESS=test_access
- JWT_REFRESH=test_refresh
- AWS_ACCESS_KEY=abcdefgh
- AWS_SECRET_KEY=dkjaosdicjsoadicj
- AWS_S3_BUCKET=test-bucket
- AWS_REGION=ap-northeast-2
최종 test compose 파일인데 외부에 포트 열어두는건 필요없었지만 추후 서술할 issue들 때문에 넣어둠.
데이터베이스 초기 설정이나 테스트 데이터베이스 url, jwt, aws 테스트 정보들을 직접 넣어둠.
이렇게 구성해놓고 shell script를 하나 작성해서 테스트 커버리지를 계산, 실패 케이스에 대해서 정리해주도록 함.
windows powershell 명령어랑 shell 명령어를 잘 몰랐기 때문에 gpt한테 일임해서 잘 안되는 부분은 다시 설명하면서 작업
그렇게 한 결과
이런식으로 전체 테스트중에 몇 개를 성공했는지를 계산, 실패하면 해당 위치를 아래 Failure Section에서 보여주도록 함
이렇게 db와 tester를 같이 도커컨테이너로 띄우고 장및빛 테스팅 자동화 여정을 마무리하려고 했음.
Issue 1. 도커 캐시 파일이 계속 커지는 상황
Rust를 최종 사용할 프로그램 빌드하게되면 Docker의 2 stage를 활용해 나오는 이미지 크기 자체는 100MB정도로 크진 않음.
1-stage에서 주로 빌드를 통해 관련 라이브러리를 가져오는 역할을 수행하는데 캐싱을 지원안함. 근데 코드 한줄만 바뀌어도 컴파일, build를 다시 수행해야하기 때문에 이 캐싱 데이터가 계속 쌓이게 됨.
그 결과 200GB의 용량이 하루 이틀 작업하니 사라지는 상황이 발생. 처음에는 도커 이미지 때문인가 하고
docker system prune으로 정리
근데도 용량이 크게 개선이 없어서, 도커의 캐시파일이 어디에 들어가는지 확인하고
C:\Users\{username}\AppData\Local\Temp 폴더가 담당하는것을 확인. script 안에서 이 폴더를 계속 지워주도록 해서 해결.
(근데 다른 캐시 데이터도 지워지는게 문제)
Issue 2. 컴파일 소요시간 증가, 지금 작업중이 아닌 기능도 테스팅 수행
Rust는 참 빠른 언어라고 생각하지만 컴파일속도는 겁나 느림.
소요시간도 계속 늘어나고, 나중엔 테스트 스크립트 실행해놓고 화장실다녀옴.
그래서 테스트 데이터베이스만 띄우고 나머지 테스트는 로컬에서 수행하는 방법으로 수정
local_test.sh가 그것이고, 실행할 때 특정 도메인 기능만 테스트할 수 있도록 수정
sh local_test.sh {테스트할 domain} 방식으로 db만 띄운 뒤 health check하여 사용 가능 여부 확인하고
컨테이너로 띄우는게 아니라 cargo test domain::{기능} 으로 로컬에 이미 있는 빌드 파일들을 활용해서 테스트를 수행하도록 함
고정적으로 소모되던 DB초기 셋팅하는 시간과 약간의 빌드? 시간 외 컴파일 시간은 획기적으로 줄이고 특정 단위기능에 집중해서 테스트 결과를 확인할 수 있게 됨.
나중에 정리하면서 알게된 내용인데 postgresql에 쿼리별 실행 횟수와 소요시간 체크도 가능하게 하는 라이브러리를 추가하여 쿼리 모니터링도 가능하게 수정 pg_stat_statements 이 그것
7. S3 연동 Image관리, JWT
이건 기능 중 하나이고 jwt 미들웨어 적용하는 것도 찾아보면 구현 예제가 많아서 다루진 않겠음.
4. 아직 수행하지 않았지만 수행해보고 싶은 내용
CI/CD, 성능 테스트 (캐싱)
맨날 기능구현만 하고 성능 모니터링이나 트래픽 테스트는 환경 구성하기가 쉽지 않은 듯.
(사실 쉽지 않다기보단 이렇게 환경 구성하는게 맞나? 싶은게 많은 듯.)
어떻게 해야될지 GPT 센세랑 얘기해보고 가닥이 잡히면 수행해볼 예정
그리고 저런 테스트 환경은 cloud에 띄우거나 해야될텐데...
ECS, K8S 같은거 공부하면서 서비스 구조 구성해봐야 될 것 같은데..