초기환경 세팅인 TestCode + RestDocs +Swagger( 테스트 코드 기반 API 문서화작업)을 진행하였다.
내가 맡은 부분은 앞으로 TDD 방법론으로 진행하는 데에 있어 초기 환경 세팅을 맡았다.
하지만 알아보니 초기환경 설정이랄 것이 딱히 없었고,
우선 양식을 만들어 큰 흐름을 파악한 이후 의존성만을 추가하였다.

👨💻 팀원의 comment대로 간단한 API가 나온 이후 진행하기로 하였다!

회원 등록 기능을 구현하기 위해 관련된 Controller, Service, Repository를 생성 및 로직을 추가하였고,
회원 예외처리를 위해 공통 핸들러, 회원 예외를 생성하였다는 팀원의 PR을 받았다! (너무 빨리 해주셔서 좋았다. 진짜로)

본격적으로 테스트 코드를 작성해 보자!!!!!
🔗 [Develop] - TDD란? 글을 참고해서 통합테스트와 단위테스트 두 개로 구분하고자 한다.
첫째, MemberServiceTest (단위테스트로 진행 예정)
둘째, MemberControllerTest(통합 테스트로 진행 예정)
그전에 의존성을 추가해 보자!
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api' // JUnit 5 테스트 작성을 위한 API
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' // JUnit 5 테스트 실행 엔진
testImplementation 'io.rest-assured:rest-assured' // REST API 테스트를 위한 라이브러리
}
tasks.test {
useJUnitPlatform() // JUnit 5 플랫폼을 사용하여 테스트 실행
}
testImplementation testFixtures(project(':core:infra')) //좀 이따 다루도록 하겠다.
Service Test
Service 로직을 단위테스트로 진행하는 이유는 다음과 같다!
우선 Service에 있는 로직은 다른 컴포넌트들과 달리 독립적으로 진행된다.
또, Service 계층은 일반적으로 의존성 주입(Dependency Injection)을 통해 외부 의존성을 관리한다.
이로 인해 테스트 시 실제 의존성을 모의 객체(mock)로 쉽게 대체할 수 있다.
이러한 이유들로 Service에 대한 단위테스트를 진행해 보겠다!
/**
* MemberService의 단위 테스트를 위한 테스트 클래스.
*
* 이 클래스는 회원 가입 기능에 대한 다양한 시나리오를 테스트:
* - 정상적인 회원 가입
* - 이미 존재하는 이메일로 가입 시도
* - 비밀번호 불일치
* - 이미 존재하는 전화번호로 가입 시도
* - 이미 존재하는 닉네임으로 가입 시도
*
* 각 테스트 케이스는 Given-When-Then 구조를 따르며,
* Mockito를 사용하여 MemberRepository와 PasswordEncoder의 동작을 모의(Mock).
*/
@ExtendWith(MockitoExtension.class) // Mockito의 확장 기능을 추가하여, @Mock, @InjectMocks와 같은 어노테이션을 사용해 Mock 객체들을 자동으로 초기화
public class MemberServiceTest {
@Mock
private MemberRepository memberRepository; // MemberRepository를 Mock으로 선언하여 테스트 시 가짜로 동작
@Mock
private PasswordEncoder passwordEncoder; // PasswordEncoder를 Mock으로 선언
@InjectMocks
private MemberService memberService; // MemberService에 Mock 객체 주입
@Test
@DisplayName("회원 가입 성공 테스트") // 테스트 이름 설정
void registerMember_Success() {
// Given: 유효한 회원가입 요청 데이터 준비
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.email("test@example.com")
.password("Pass123!@")
.passwordConfirm("Pass123!@")
.phoneNumber("010-1234-5678")
.nickname("testUser")
.build();
// Mock 설정: 중복 체크 시 모두 false 반환, 비밀번호 인코딩, save 호출 시 객체 반환
when(memberRepository.existsByEmail(anyString())).thenReturn(false);
when(memberRepository.existsByPhoneNumber(anyString())).thenReturn(false);
when(memberRepository.existsByNickname(anyString())).thenReturn(false);
when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword");
// 저장된 객체를 그대로 반환하도록 설정
when(memberRepository.save(any(Member.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When: 회원가입 서비스 호출
Member result = memberService.registerMember(requestDto);
// Then: 결과 검증 - 반환된 Member 객체가 null이 아닌지 확인
assertNotNull(result);
// save 메소드가 호출되었는지 확인
verify(memberRepository).save(any(Member.class));
}
@Test
@DisplayName("이메일 중복으로 인한 회원가입 실패 테스트") // 이메일 중복으로 인한 실패를 검증
void registerMember_EmailAlreadyExists() {
// Given: 이미 존재하는 이메일로 회원가입 요청
MemberRegisterRequestDto requestDto = new MemberRegisterRequestDto();
requestDto.setEmail("existing@example.com");
// 이메일 중복 체크 시 true 반환 (이미 존재함)
when(memberRepository.existsByEmail("existing@example.com")).thenReturn(true);
// When & Then: 이메일 중복으로 예외 발생 여부 검증
MemberException exception = assertThrows(MemberException.class,
() -> memberService.registerMember(requestDto));
// 발생한 예외의 에러 코드가 EXISTS_MEMBER_EMAIL인지 확인
assertEquals(EXISTS_MEMBER_EMAIL, exception.getErrorCode());
}
@Test
@DisplayName("비밀번호 불일치로 인한 회원가입 실패 테스트") // 비밀번호 불일치를 검증
void registerMember_PasswordMismatch() {
// Given: 비밀번호와 확인 비밀번호가 일치하지 않는 요청 데이터
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.password("password123")
.passwordConfirm("password456")
.build();
// When & Then: 비밀번호 불일치로 인한 예외 발생 여부 검증
MemberException exception = assertThrows(MemberException.class,
() -> memberService.registerMember(requestDto));
// 발생한 예외의 에러 코드가 NOT_MATCHED_PASSWORD인지 확인
assertEquals(NOT_MATCHED_PASSWORD, exception.getErrorCode());
}
@Test
@DisplayName("전화번호 중복으로 인한 회원가입 실패 테스트") // 전화번호 중복 검증
void registerMember_PhoneNumberAlreadyExists() {
// Given: 이미 존재하는 전화번호로 회원가입 요청
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.email("test@example.com")
.password("password123")
.passwordConfirm("password123")
.phoneNumber("01012345678")
.build();
// Mock 설정: 이메일 중복은 없지만 전화번호 중복이 있음
when(memberRepository.existsByEmail(anyString())).thenReturn(false);
when(memberRepository.existsByPhoneNumber("01012345678")).thenReturn(true);
// When & Then: 전화번호 중복으로 예외 발생 여부 검증
MemberException exception = assertThrows(MemberException.class,
() -> memberService.registerMember(requestDto));
// 발생한 예외의 에러 코드가 EXISTS_MEMBER_PHONE_NUMBER인지 확인
assertEquals(EXISTS_MEMBER_PHONE_NUMBER, exception.getErrorCode());
}
@Test
@DisplayName("닉네임 중복으로 인한 회원가입 실패 테스트") // 닉네임 중복 검증
void registerMember_NicknameAlreadyExists() {
// Given: 이미 존재하는 닉네임으로 회원가입 요청
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.email("test@example.com")
.password("password123")
.passwordConfirm("password123")
.phoneNumber("01012345678")
.nickname("existingNickname")
.build();
// Mock 설정: 이메일, 전화번호 중복 없음, 닉네임 중복 있음
when(memberRepository.existsByEmail(anyString())).thenReturn(false);
when(memberRepository.existsByPhoneNumber(anyString())).thenReturn(false);
when(memberRepository.existsByNickname("existingNickname")).thenReturn(true);
// When & Then: 닉네임 중복으로 예외 발생 여부 검증
MemberException exception = assertThrows(MemberException.class,
() -> memberService.registerMember(requestDto));
// 발생한 예외의 에러 코드가 EXISTS_MEMBER_NICKNAME인지 확인
assertEquals(EXISTS_MEMBER_NICKNAME, exception.getErrorCode());
}
}
주석에 기재돼있듯이 다음과 같은 테스트를 진행했다.
주요 테스트 시나리오:
- 정상적인 회원 가입
-모든 값이 유효할 때 회원 가입이 성공적으로 이루어지는지 검증한다. - 비밀번호 불일치로 가입 시도
-입력한 비밀번호와 확인 비밀번호가 일치하지 않을 때 예외를 발생시키는지 테스트한다. - 이미 존재하는 값으로 가입 시도
-이미 사용 중인 이메일, 전화번호, 닉네임으로 가입하려 할 때 예외가 발생하는지 확인한다.
🚦 추가할 테스트: 유효하지 않은 값
이메일 형식이 맞지 않거나 필수 값이 누락된 경우에도 예외가 발생하는지 테스트를 추가할 필요가 있다.

이 테스트는 Mock 객체를 이용해 외부 의존성(예: DB, 외부 API 등)을 처리하고, 서비스 로직을 집중적으로 테스트한다.
Mockito를 사용해 실제 동작을 가짜(Mock)로 모방함으로써, 복잡한 설정 없이도
회원가입 서비스가 의도한 대로 동작하는지 검증할 수 있다.
💡 즉, 가짜 부품을 가져와서 서비스 로직이 잘 돌아가는지 확인하는 것이다
그래서 실제 DB나 복잡한 설정 없이도 서비스 기능만을 검증하는 단위 테스트를 진행해 본 것이다!
Controller Test
초기에 컨트롤러 테스트도 나는 Mockito를 통해로 진행하였다.

그 이후 Pr을 올렸을 때 이러한 Feedback을 받았다.
우선 모르는 키워드인 TestContainer에 대해 간략히 알아보았다.
TestContainers는 테스트 환경에서 Docker 컨테이너를 사용해 실제 서비스에 가까운 환경을 구축할 수 있게 해주는 라이브러리이다. 이를 통해 DB, 메시지 큐 같은 외부 시스템을 컨테이너로 띄워서 통합 테스트를 쉽게 할 수 있다.
즉, 실제 서비스처럼 DB를 테스트용으로 임시로 띄우고, 테스트가 끝나면 바로 없애버리는 것이다.
이 설정을 이용하면 복잡한 환경 설정 없이도 테스트가 가능해진다.
간단한 흐름은 다음과 같다.

더 구체적인 흐름은 다음과 같다

TestContainer Flow
- 테스트 시작: 테스트가 시작되면 TestContainers가 활성화
- Docker에 MySQL 컨테이너 생성 요청: TestContainers가 MySQL 컨테이너를 생성하고 실행할 것을 요청
- MySQL 컨테이너 실행: 실제로 MySQL 컨테이너가 실행되고, 이 환경에서 테스트가 진행
- testdb 데이터베이스 생성: 테스트 전용 데이터베이스가 생성
- 테스트 코드 실행: 이 환경에서 테스트 코드가 실행
- 테스트 완료: 테스트가 완료되면, MySQL 컨테이너가 종료되고 삭제
그럼 테스트 컨테이너 설정을 해보자
@Testcontainers // Testcontainers를 사용하여 독립된 MySQL 환경에서 테스트 실행
public class MemberControllerTest {
// MySQL Testcontainer 설정: 테스트마다 새로운 MySQL 환경을 제공
@Container
public static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb") // 데이터베이스 이름 설정
.withUsername("test") // 사용자 이름 설정
.withPassword("test") // 비밀번호 설정
.withInitScript("db/initdb.d/1-schema.sql"); // 초기 스키마 설정
// MySQL Testcontainer에서 제공한 설정 정보를 동적으로 Spring에 반영
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl); // 데이터베이스 URL 등록
registry.add("spring.datasource.username", mysql::getUsername); // 사용자 이름 등록
registry.add("spring.datasource.password", mysql::getPassword); // 비밀번호 등록
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
}
}
이렇게 세팅을 완료했다.
우리 팀이 미리 정해둔 schema는 다음과 같다.
이 세팅을 적용하면, 미리 정해놓은 테이블 구조대로 가상 DB에 테이블이 생성되고, 그 위에서 값이 입력되는 테스트를 진행할 수 있다.
# 회원
CREATE TABLE IF NOT EXISTS member
(
member_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 회원 식별자
email VARCHAR(30) NOT NULL, -- 회원 이메일
password VARCHAR(80) NOT NULL, -- 회원 비밀번호
nickname VARCHAR(20) NOT NULL, -- 회원 닉네임
phone_number VARCHAR(20) NOT NULL, -- 회원 전화번호
name VARCHAR(20) NOT NULL, -- 회원 이름
status VARCHAR(20) NOT NULL, -- 회원 상태
role VARCHAR(20) NOT NULL, -- 회원 권한
provider VARCHAR(20), -- 회원 제공자(ex KAKAO, NAVER 등)
created_at DATETIME, -- 회원 생성일
created_by VARCHAR(20), -- 회원 생성자
updated_at DATETIME, -- 회원 수정일
updated_by VARCHAR(20) -- 회원 수정자
);
🤔 그렇다면 이 설정을 매번 복사해서 붙여 넣어야 할까?
공통 설정을 따로 분리해서 상속받는 방식으로 관리할 수 없을까? 이런 생각이 들었다.
처음에는 infra/test 패키지에 TestContainerConfig 파일을 넣었지만, api 모듈 패키지에서 이를 인식하지 못하는 문제가 있었다.
그 이유는 Gradle에서 테스트 관련 클래스가 모듈 간에 공유되지 않기 때문이다.
기본적으로 테스트 코드는 메인 코드와는 달리 모듈 간에 직접 참조할 수 없기 때문에,
설정 파일이 있는 모듈의 테스트 코드가 다른 모듈에서 인식되지 않았던 것이다.
그때 팀원의 피드백(항상 감사 ㅎ)을 받았고, 'testFixtures'라는 키워드와 함께 🔗 테스트 의존성 관리에 관한 참고자료를 받았다.
예를 들어, TestContainerConfig와 같은 설정 파일을 testFixtures로 분리해 두면, 모든 모듈에서 이를 재사용할 수 있기 때문에, 각 모듈에서 동일한 설정을 반복할 필요가 없어지는 것이다.
💡 쉽게 말하면, testFixtures 플러그인은 테스트에서 자주 사용되는 설정이나 코드를 한 번 정의해 두고, 여러 테스트에서 공유할 수 있게 해 준다. 이를 통해 중복된 설정을 줄이고, 공통적인 설정을 한 곳에서 쉽게 관리할 수 있다.
- TestContainerConfig
공통 Config설정을 위해선 test-fixtures plugin 설정과
testFixturesImplementation 키워드를 사용해 testFixtures에서 필요한 의존성을 추가한다.
plugins {
id 'java-test-fixtures' // 자동 testfixures 패키지 생성
}
dependencies{
// TestFixtures 의존성
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-test'
testFixturesImplementation 'org.testcontainers:testcontainers'
testFixturesImplementation 'org.testcontainers:mysql'
testFixturesImplementation 'org.testcontainers:junit-jupiter'
}
그 이후 TestContanierConfig를 설정하였다.
@Testcontainers // Testcontainers를 사용하여 독립된 MySQL 환경에서 테스트 실행
public class TestContainerConfig {
// MySQL Testcontainer 설정: 테스트마다 새로운 MySQL 환경을 제공
@Container
public static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb") // 데이터베이스 이름 설정
.withUsername("test") // 사용자 이름 설정
.withPassword("test") // 비밀번호 설정
.withInitScript("db/initdb.d/1-schema.sql"); // 초기 스키마 설정
// MySQL Testcontainer에서 제공한 설정 정보를 동적으로 Spring에 반영
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl); // 데이터베이스 URL 등록
registry.add("spring.datasource.username", mysql::getUsername); // 사용자 이름 등록
registry.add("spring.datasource.password", mysql::getPassword); // 비밀번호 등록
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); // 애플리케이션 실행 시 Hibernate가 자동으로 테이블을 생성하도록 설정
}
}
🤦 TestContainers를 사용할 때 ddl-auto=none 설정 때문에 DB는 생성됐지만, 테이블이 생성되지 않는 문제를 겪었다.
ddl-auto=none은 테이블 자동 생성이나 수정을 막는 옵션이라, 수동으로 테이블을 만들어야 했다.
이 문제는 (registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");로 설정을 변경해 해결할 수 있었다.
자 이제 본격적으로 MemberControllerTest를 작성해 보자!
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MemberControllerTest extends TestContainerConfig {
@LocalServerPort
private int port; // 테스트 시 사용할 랜덤 포트 설정
@Autowired
private MemberRepository memberRepository; // 실제 데이터베이스 상호작용을 위한 MemberRepository
// RestAssured가 사용할 포트를 설정
@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.basePath = "/v1";
}
@Test
@DisplayName("회원 가입 성공 테스트") // 성공적인 회원 가입을 테스트
void registerMember_Success() {
// Given: 회원가입 요청에 필요한 데이터 준비
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.email("test@example.com")
.password("Pass123!@")
.passwordConfirm("Pass123!@")
.phoneNumber("010-1234-5678")
.nickname("testUser")
.build();
// When: 회원가입 API 호출
// Then: 회원이 성공적으로 생성되었는지 확인
given()
.contentType(ContentType.JSON) // 요청 본문의 Content-Type 설정 (JSON 형식)
.body(requestDto) // 요청 본문으로 DTO 전송
.when()
.post("/member/register") // POST 요청을 보냄 (회원가입 API)
.then()
.statusCode(201) // 응답 상태 코드가 201(CREATED)인지 확인
.body("code", equalTo(201)) // 응답 본문에서 상태 코드가 201인지 확인
.body("message", equalTo("정상적으로 생성되었습니다.")); // 성공 메시지 확인
}
@Test
@DisplayName("유효하지 않은 값 예외처리 테스트") // 잘못된 입력값으로 인한 예외처리 테스트
void registerMember_InvalidInput() {
// Given: 유효하지 않은 회원가입 요청 데이터 준비 (이메일 누락)
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.password("Pass123!@")
.passwordConfirm("Pass123!@")
.phoneNumber("010-1234-5678")
.nickname("testUser")
.build();
// When: 잘못된 회원가입 API 호출
// Then: 유효성 검사 실패로 400(BAD REQUEST) 응답 확인
given()
.contentType(ContentType.JSON) // 요청 본문의 Content-Type 설정 (JSON 형식)
.body(requestDto) // 요청 본문으로 DTO 전송
.when()
.post("/member/register") // POST 요청을 보냄 (회원가입 API)
.then()
.statusCode(400) // 응답 상태 코드가 400(BAD REQUEST)인지 확인
.body("errorCode", equalTo(400)) // 응답 본문에서 에러 코드가 400인지 확인
.body("errorMessage", equalTo("유효성 검사 오류")) // 에러 메시지 확인
.body("validation.email", equalTo("이메일은 필수 항목입니다.")); // 이메일 유효성 검사 메시지 확인
}
@Test
@DisplayName("중복 이메일 회원가입 테스트") // 중복된 이메일로 인한 회원가입 실패 테스트
void registerMember_DuplicateEmail() {
// Given: 이미 존재하는 이메일로 회원 저장
Member existingMember = Member.builder()
.email("existing@example.com").build();
memberRepository.save(existingMember); // 기존 회원을 DB에 저장하여 중복된 상태를 만듦
// 새로운 회원가입 요청 데이터 준비 (중복된 이메일 사용)
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.email("existing@example.com")
.password("Pass123!@")
.passwordConfirm("Pass123!@")
.phoneNumber("010-1234-5678")
.nickname("testUser")
.build();
// When: 중복된 이메일로 회원가입 API 호출
// Then: 중복된 이메일로 인해 409(CONFLICT) 응답 확인
given()
.contentType(ContentType.JSON) // 요청 본문의 Content-Type 설정 (JSON 형식)
.body(requestDto) // 요청 본문으로 DTO 전송
.when()
.post("/member/register") // POST 요청을 보냄 (회원가입 API)
.then()
.statusCode(409) // 응답 상태 코드가 409(CONFLICT)인지 확인
.body("errorCode", equalTo(409)) // 응답 본문에서 에러 코드가 409인지 확인
.body("errorMessage", equalTo("이미 등록된 회원 이메일입니다.")); // 이메일 중복 에러 메시지 확인
}
}
이 테스트 클래스는 회원 가입 기능에 대한 API 테스트를 수행하는 코드이다.
TestContainers와 Spring Boot 환경을 활용하여 실제 DB와 비슷한 환경에서 테스트를 진행합니다.
주요 테스트 시나리오:
- 회원 가입 성공 테스트
-유효한 회원가입 요청이 있을 때, 회원이 정상적으로 생성되고, 응답 코드가 201(CREATED)인지 확인한다. - 유효하지 않은 값 예외처리 테스트
-이메일이 누락된 잘못된 회원가입 요청에 대해, 유효성 검사 실패로 400(BAD REQUEST) 응답이 반환되는지 확인한다. - 중복된 이메일 회원가입 테스트
-이미 등록된 이메일로 회원가입을 시도할 때, 409(CONFLICT) 상태 코드와 함께 중복된 이메일에 대한
에러 메시지가 반환되는지 확인한다.

이 테스트는 RestAssured를 이용해 HTTP 요청을 보내고 응답을 검증한다.
각 테스트는 Given-When-Then 구조를 따르며, 회원가입 API가 의도한 대로 동작하는지 확인하는 역할을 한다.
💡 즉, 이 테스트는 회원가입 API가 잘 작동하는지 확인하는 과정이다.
API가 정상적으로 회원을 등록하고, 오류 상황에서도 제대로 된 에러 메시지를 반환하는지 검증하는 것이라고 보면 된다.
🙇♂️ 결론
테스트 코드를 작성하면서 정말 많은 것을 배웠다. 팀원의 코드를 참고하며, 성공 시 응답 코드와 메시지, 에러 발생 시 코드와 메시지, 그리고 예외 처리 핸들러까지 다양한 부분을 테스트해야 한다는 것을 깨달았다.
처음에는 setter를 사용해서 설정했지만, 이후에 Builder 패턴을 통해 더 간결하게 리팩터링 할 수 있었다.
* 회원가입에는 Builder 패턴을 사용해 모든 필드를 한 번에 설정할 때 쓰이고
회원 정보 수정에는 Setter를 사용해 필요한 부분만 수정에 쓰인다.
즉 , 회원가입 테스트였기에 builder를 쓰는 게 적합했다!
또한, 팀원인 성오님이 제시해 준 여러 키워드 덕분에 문제를 해결해 나가는 과정이 정말 재미있었다.
하지만, 이 테스트는 어디까지나 초기 환경 설정을 위한 것이었고, 이제 본격적으로 TDD(테스트 주도 개발) 방식으로
개발을 진행하기로 했다. 솔직히 잘할 수 있을지 걱정도 되지만, 직접 부딪혀 봐야 한다고 생각한다.
물론, 기본기를 탄탄히 다진 상태에서 도전한다면, 이런 과정이 나에게 큰 도움이 될 것 같다. 파이팅!!!

'Project > 우리집 레시피' 카테고리의 다른 글
| [Project] 우리집 레시피 - TestCode + RestDocs + Swagger(API 문서화) (0) | 2024.09.13 |
|---|---|
| [Project] 우리집 레시피 - 첫 정규 회의 (1) | 2024.09.06 |