NEXTSTEP에서 진행하는 교육과정 ATDD와 함께 클린 API로 가는 길 4기다. (글을 작성하는 시점에서는 과정명이 ATDD, 클린 코드 with Spring으로 변경되었다.)
🚀 1단계 - 토큰 기반 로그인 구현
요구사항
- AuthAcceptanceTest의 myInfoWithBearerAuth 테스트 메서드를 성공시키기
- TokenAuthenticationInterceptor 구현하기
- MemberAcceptanceTest의 manageMyInfo 성공시키기
- @AuthenticationPrincipal을 활용하여 로그인 정보 받아오기
3주차 1단계는 토큰 기반 로그인 구현이 주제로 잘모르는 JWT 인증이라 많이 난감했지만
다행히 3주차 수업에서 배경 지식과 자세한 가이드를 제공해서 모르는 사람도 무리 없이 진행할 수 있다.
인증에 대한 개념 수업이 아니라 인증 기반 인수 테스트에서 고민을 할 수 있는 미션을 준비했다.
제공된 코드를 보면 HandlerInterceptor를 상속받은 세션 인증 로그인의 SessionAuthenticationInterceptor와 토큰 기반 로그인의 TokenAuthenticationInterceptor로 구분된다.
제공받은 SessionAuthenticationInterceptor 코드는 다음과 같다.
public class SessionAuthenticationInterceptor implements HandlerInterceptor {
public static final String USERNAME_FIELD = "username";
public static final String PASSWORD_FIELD = "password";
private CustomUserDetailsService userDetailsService;
public SessionAuthenticationInterceptor(CustomUserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
AuthenticationToken token = convert(request);
Authentication authentication = authenticate(token);
HttpSession httpSession = request.getSession();
httpSession.setAttribute(SPRING_SECURITY_CONTEXT_KEY, new SecurityContext(authentication));
response.setStatus(HttpServletResponse.SC_OK);
return false;
}
public AuthenticationToken convert(HttpServletRequest request) {
Map<String, String[]> paramMap = request.getParameterMap();
String principal = paramMap.get(USERNAME_FIELD)[0];
String credentials = paramMap.get(PASSWORD_FIELD)[0];
return new AuthenticationToken(principal, credentials);
}
public Authentication authenticate(AuthenticationToken token) {
String principal = token.getPrincipal();
LoginMember userDetails = userDetailsService.loadUserByUsername(principal);
checkAuthentication(userDetails, token);
return new Authentication(userDetails);
}
private void checkAuthentication(LoginMember userDetails, AuthenticationToken token) {
if (userDetails == null) {
throw new AuthenticationException();
}
if (!userDetails.checkPassword(token.getCredentials())) {
throw new AuthenticationException();
}
}
}
토큰 인증 TokenAuthenticationInterceptor 코드는 중간중간 비어있는 형태로 여기를 채워 넣어야 한다.
public class TokenAuthenticationInterceptor implements HandlerInterceptor {
private CustomUserDetailsService customUserDetailsService;
private JwtTokenProvider jwtTokenProvider;
public TokenAuthenticationInterceptor(CustomUserDetailsService customUserDetailsService, JwtTokenProvider jwtTokenProvider) {
this.customUserDetailsService = customUserDetailsService;
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
AuthenticationToken authenticationToken = convert(request);
Authentication authentication = authenticate(authenticationToken);
// TODO: authentication으로 TokenResponse 추출하기
TokenResponse tokenResponse = null;
String responseToClient = new ObjectMapper().writeValueAsString(tokenResponse);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getOutputStream().print(responseToClient);
return false;
}
public AuthenticationToken convert(HttpServletRequest request) throws IOException {
// TODO: request에서 AuthenticationToken 객체 생성하기
String principal = "";
String credentials = "";
return new AuthenticationToken(principal, credentials);
}
public Authentication authenticate(AuthenticationToken authenticationToken) {
// TODO: AuthenticationToken에서 AuthenticationToken 객체 생성하기
return new Authentication(null);
}
}
제공받은 인수테스트 코드를 실행하면 실패한다. 통과하려면 위의 코드를 완성해야한다.
class AuthAcceptanceTest extends AcceptanceTest {
...
@DisplayName("Bearer Auth")
@Test
void myInfoWithBearerAuth() {
회원_생성_요청(EMAIL, PASSWORD, AGE);
String accessToken = 로그인_되어_있음(EMAIL, PASSWORD);
ExtractableResponse<Response> response = 내_회원_정보_조회_요청(accessToken);
회원_정보_조회됨(response, EMAIL, AGE);
}
}
실습 가이드에 제공된 뼈대 코드 설명과 힌트를 제공하니 참고해서 진행하면 인수테스트가 통과하는 걸 볼 수 있다.
mock 테스트의 신뢰성은?
다음은 토큰 인증 단위 테스트를 mock으로 진행했다가 만난 문제의 코드다.
@ExtendWith(MockitoExtension.class)
class TokenAuthenticationInterceptorTest {
private static final String EMAIL = "email@email.com";
private static final String PASSWORD = "password";
public static final String JWT_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.ih1aovtQShabQ7l0cINw4k1fagApg3qLWiB8Kt59Lno";
@Mock
private UserDetailService userDetailsService;
@Mock
private JwtTokenProvider jwtTokenProvider;
@Spy
private ObjectMapperBean objectMapper;
@Mock
private AuthenticationConverter converter;
@InjectMocks
private TokenAuthenticationInterceptor interceptor;
@Test
void preHandle() throws IOException {
// given
LoginMember member = new LoginMember(1L, EMAIL, PASSWORD, 20);
when(converter.convert(any())).thenReturn(new AuthenticationToken(EMAIL, PASSWORD));
when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(member);
when(jwtTokenProvider.createToken(anyString())).thenReturn(JWT_TOKEN);
// when
MockHttpServletRequest request = createMockRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
interceptor.preHandle(request, response, new Object());
// then
assertAll(
() -> assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_OK),
() -> assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE),
() -> assertThat(response.getContentAsString()).isEqualTo(new ObjectMapper().writeValueAsString(new TokenResponse(JWT_TOKEN)))
);
}
...
}
단위테스트는 요청과 응답까지 정해놓고 진행하는 mock 테스트다 보니 문제없이 통과가 되었는데
실객체로 진행하는 인수테스트 AuthAcceptanceTest의 myInfoWithBearerAuth 테스트에서 인증이 되지 않아 실패했다.
원인을 찾아보니 preHandle의 의존성에서 문제가 있었다. (mock 테스트만 작성했었는데, 곧바로 실객체 테스트도 추가했다)
단위테스트를 실객체로 했다면 문제를 발견했을텐데, mock 객체를 언제 사용하면 좋을지와 신뢰해도 되는지 고민하게 되었다.
🚀 2단계 - 인증 로직 리팩터링
요구사항
- 1,2단계에서 구현한 인증 로직에 대한 리팩터링을 진행하세요
- 내 정보 수정 / 삭제 기능을 처리하는 기능을 구현하세요.
- Controller에서 @애너테이션을 활용하여 Login 정보에 접근
2단계는 1단계 코드를 리팩토링하는 과정이다.
1단계에서 구현한 TokenAuthenticationInterceptor 코드와 제공받은 SessionAuthenticationInterceptor 코드를 비교해보면
세션과 토큰 데이터를 가져오고 검증하는 부분만 다르고 나머지 뼈대는 동일하다.
public class TokenAuthenticationInterceptor implements HandlerInterceptor {
private CustomUserDetailsService customUserDetailsService;
private JwtTokenProvider jwtTokenProvider;
private ObjectMapperBean objectMapper
public TokenAuthenticationInterceptor(CustomUserDetailsService customUserDetailsService, JwtTokenProvider jwtTokenProvider, ObjectMapperBean objectMapper) {
this.customUserDetailsService = customUserDetailsService;
this.jwtTokenProvider = jwtTokenProvider;
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
AuthenticationToken authenticationToken = convert(request);
Authentication authentication = authenticate(authenticationToken);
String payload = objectMapper.writeValueAsString(authentication.getPrincipal());
String token = jwtTokenProvider.createToken(payload);
TokenResponse tokenResponse = new TokenResponse(token);
String responseToClient = objectMapper.writeValueAsString(tokenResponse);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getOutputStream().print(responseToClient);
return false;
}
public AuthenticationToken convert(HttpServletRequest request) throws IOException {
TokenRequest tokenRequest = objectMapper.readValue(request.getInputStream(), TokenRequest.class);
String principal = tokenRequest.getEmail();
String credentials = tokenRequest.getPassword();
return new AuthenticationToken(principal, credentials);
}
public Authentication authenticate(AuthenticationToken authenticationToken) {
String principal = authenticationToken.getPrincipal();
LoginMember userDetails = customUserDetailsService.loadUserByUsername(principal);
checkAuthentication(userDetails, authenticationToken);
return new Authentication(userDetails);
}
private void checkAuthentication(LoginMember userDetails, AuthenticationToken token) {
if (userDetails == null) {
throw new AuthenticationException();
}
if (!userDetails.checkPassword(token.getCredentials())) {
throw new AuthenticationException();
}
}
}
public class SessionAuthenticationInterceptor implements HandlerInterceptor {
public static final String USERNAME_FIELD = "username";
public static final String PASSWORD_FIELD = "password";
private CustomUserDetailsService userDetailsService;
public SessionAuthenticationInterceptor(CustomUserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
AuthenticationToken token = convert(request);
Authentication authentication = authenticate(token);
HttpSession httpSession = request.getSession();
httpSession.setAttribute(SPRING_SECURITY_CONTEXT_KEY, new SecurityContext(authentication));
response.setStatus(HttpServletResponse.SC_OK);
return false;
}
public AuthenticationToken convert(HttpServletRequest request) {
Map<String, String[]> paramMap = request.getParameterMap();
String principal = paramMap.get(USERNAME_FIELD)[0];
String credentials = paramMap.get(PASSWORD_FIELD)[0];
return new AuthenticationToken(principal, credentials);
}
public Authentication authenticate(AuthenticationToken token) {
String principal = token.getPrincipal();
LoginMember userDetails = userDetailsService.loadUserByUsername(principal);
checkAuthentication(userDetails, token);
return new Authentication(userDetails);
}
private void checkAuthentication(LoginMember userDetails, AuthenticationToken token) {
if (userDetails == null) {
throw new AuthenticationException();
}
if (!userDetails.checkPassword(token.getCredentials())) {
throw new AuthenticationException();
}
}
}
동일한 뼈대를 가지기 때문에 추상클래스로 분리하기로 한다. 그 외에도 분리할 구간이 존재하면 진행한다.
중요한 점은 기존 코드, 기존 테스트를 리팩토링하는게 아니라 복제해서 리팩토링을 진행한다. 완료가 되면 기존 코드를 교체한다.
인증 구조가 헷갈리면 힌트를 보면서 진행하는 것이 많이 편하다.
여기서 분리된 것은 AuthenticationInterceptor, SecurityContextInterceptor, AuthenticationConverter다.
추상클래스로 분리된 것은 AuthenticationInterceptor, SecurityContextInterceptor 이며
인터페이스로 분리된 것은 AuthenticationConverter 다.
AuthenticationConverter만 인터페이스로 분리된 이유는 세션과 토큰 인증 복호화 로직은 동일한 뼈대가 없고 로직이 전혀 다르기 때문이다.
코드가 분리되었지만 아직 리팩토링이 완료된 게 아니다.
의존성 문제가 남아있는데 auth 패키지의 TokenAuthenticationInterceptor, SessionAuthenticationInterceptor는 member 패키지를 의존하고 있으며 해당 의존성의 코드는 다음과 같다.
import nextstep.member.application.CustomUserDetailsService;
...
public class TokenAuthenticationInterceptor extends AuthenticationInterceptor {
private CustomUserDetailsService customUserDetailsService;
...
}
package nextstep.member.application;
@Service
public class CustomUserDetailsService {
private MemberRepository memberRepository;
public CustomUserDetailsService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public LoginMember loadUserByUsername(String email) {
Member member = memberRepository.findByEmail(email).orElseThrow(RuntimeException::new);
return LoginMember.of(member);
}
}
member 패키지의 MemberController는 인증을 위해서 auth 패키지를 의존하고 있다.
import nextstep.auth.authorization.AuthenticationPrincipal;
...
@RestController
@RequestMapping("/members")
public class MemberController {
...
@GetMapping("/me")
public ResponseEntity<MemberResponse> findMemberOfMine(@AuthenticationPrincipal LoginMember loginMember) {
MemberResponse member = memberService.findMember(loginMember.getId());
return ResponseEntity.ok().body(member);
}
@PutMapping(value = "/me")
public ResponseEntity<MemberResponse> updateMemberOfMine(@AuthenticationPrincipal LoginMember loginMember,
@RequestBody MemberRequest param) {
memberService.updateMember(loginMember.getId(), param);
return ResponseEntity.ok().build();
}
@DeleteMapping("/me")
public ResponseEntity<MemberResponse> deleteMemberOfMine(@AuthenticationPrincipal LoginMember loginMember) {
memberService.deleteMember(loginMember.getId());
return ResponseEntity.noContent().build();
}
}
서로 의존하고 있다 보니 한 방향만 의존할 수 있도록 의존성을 끊어야 한다.
auth 패키지는 인증을 위한 패키지로 공통이기 때문에 auth에서 외부 의존성을 제거한다.
auth 내부에서 member 의존성을 끊기 위한 추상화를 생성하고 member 패키지가 해당 추상화를 의존하게 한다.
public interface UserDetailService {
UserDetails loadUserByUsername(String email);
}
import nextstep.auth.authentication.UserDetailService;
...
@Service
public class CustomUserDetailsService implements UserDetailService {
...
public UserDetails loadUserByUsername(String email) {
Member member = memberRepository.findByEmail(email).orElseThrow(RuntimeException::new);
return LoginMember.of(member);
}
}
🚀 3단계 - 즐겨찾기 기능 구현
기능 요구사항
- 요구사항 설명에서 제공되는 추가된 요구사항을 기반으로 즐겨 찾기 기능을 리팩터링하세요
- 추가된 요구사항을 정의한 인수 조건을 도출하세요.
- 인수 조건을 검증하는 인수 테스트를 작성하세요.
- 예외 케이스에 대한 검증도 포함하세요.
- 로그인이 필요한 API 요청 시 유효하지 않은 경우 401 응답 내려주기
프로그래밍 요구사항
- 인수 테스트 주도 개발 프로세스에 맞춰서 기능을 구현하세요.
- 요구사항 설명을 참고하여 인수 조건을 정의
- 인수 조건을 검증하는 인수 테스트 작성
- 인수 테스트를 충족하는 기능 구현
- 인수 조건은 인수 테스트 메서드 상단에 주석으로 작성하세요.
- 뼈대 코드의 인수 테스트를 참고
- 인수 테스트 이후 기능 구현은 TDD로 진행하세요.
- 도메인 레이어 테스트는 필수
- 서비스 레이어 테스트는 선택
3주차 마지막 단계는 자세한 요구사항과 요구사항 설명으로 Request / Response 를 제공해준다. 아래는 그중 생성에 관련된 값이다.
// 요청
POST /favorites HTTP/1.1
authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJpZFwiOjEsXCJlbWFpbFwiOlwiZW1haWxAZW1haWwuY29tXCIsXCJwYXNzd29yZFwiOlwicGFzc3dvcmRcIixcImFnZVwiOjIwLFwicHJpbmNpcGFsXCI6XCJlbWFpbEBlbWFpbC5jb21cIixcImNyZWRlbnRpYWxzXCI6XCJwYXNzd29yZFwifSIsImlhdCI6MTYxNjQyMzI1NywiZXhwIjoxNjE2NDI2ODU3fQ.7PU1ocohHf-5ro78-zJhgjP2nCg6xnOzvArFME5vY-Y
accept: */*
content-type: application/json; charset=UTF-8
content-length: 27
host: localhost:60443
connection: Keep-Alive
user-agent: Apache-HttpClient/4.5.13 (Java/1.8.0_252)
accept-encoding: gzip,deflate
{
"source": "1",
"target": "3"
}
// 응답
HTTP/1.1 201 Created
Keep-Alive: timeout=60
Connection: keep-alive
Set-Cookie: JSESSIONID=204A5CC2753073508BE5CE2343AE26F5; Path=/; HttpOnly
Content-Length: 0
Date: Mon, 22 Mar 2021 14:27:37 GMT
Location: /favorites/1
마지막 단계는 3주차에서 가장 무난하게 진행이 가능하다.
이번 단계에서 가장 큰 고민을 했던 게 어떤 패키지가 어울리지 정하는 거였다.
- 새로운 favorite 패키지를 추가해야 할까?
- 기존의 member, subway 패키지 중에서 포함시켜야 할까?
여기저기 패키지를 왔다 갔다 하다가 마지막에는 로그인한 member를 위한 기능이기 때문에 member 패키지에 넣었다. (비로그인에도 일시적인 즐겨찾기가 가능했거나 즐겨찾기 관련된 기능들이 많다면 더 고민을 해봤을 거 같다.)
그렇지만 끝까지 확신이 안 들기 때문에 결국 질문을 남겼다.
진행하면서 즐겨찾기의 위치에 대한 고민이 있었습니다.
member와 subway 패키지 중에서 어디에 위치하면 좋을지 고민하다가
처음에는 즐겨찾기를 member 패키지에서 구현했지만, subway 패키지로 옮기는 게 좋을 정도로 subway 의존성이 많아 subway로 변경하기도 했습니다.
하지만 즐겨찾기는 멤버에 종속적이란 생각에 다시 원상복구를 했습니다.
이런 상황을 겪게 되면 역할과 책임의 위치를 정하는 기준이 있는지 궁금합니다.
해당 피드백으로 즐겨찾기 엔티티 코드를 되돌아봤다.
import nextstep.subway.domain.Station;
@Entity
public class Favorite extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long memberId;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "up_station_id")
private Station upStation;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "down_station_id")
private Station downStation;
...
}
엔티티를 보면 member를 간접 참조하고 있고 subway.domain.Station을 직접 참조하고 있기 때문에 subway가 적당한 패키지다.
* 후기를 작성하는 지금 다시 생각해지만 직접참조를 간접참조로 바꾸면 어떻게 될까? (DDD 에서는 직접참조가 아니라 간접참조를 권장한다)
후기
3주차 수업에서는 다양한 걸 배웠다.
가장 큰 것은 테스트 리팩토링이다.
이전까지 인수 테스트를 작성했다면 user story 중점으로 단건으로 진행했었다.
Feature: 즐겨찾기를 관리한다.
Background
Given 지하철역 등록되어 있음
And 지하철 노선 등록되어 있음
And 지하철 노선에 지하철역 등록되어 있음
And 회원 등록되어 있음
And 로그인 되어있음
Scenario: 즐겨찾기를 생성
When 즐겨찾기 생성을 요청
Then 즐겨찾기 생성됨
Scenario: 즐겨찾기 목록 조회
Given 즐겨찾기 생성을 요청
When 즐겨찾기 목록 조회 요청
Then 즐겨찾기 목록 조회됨
Scenario: 즐겨찾기를 삭제
Given 즐겨찾기 생성을 요청
Given 즐겨찾기 목록 조회 요청
When 즐겨찾기 삭제 요청
Then 즐겨찾기 삭제됨
user story 방식은 인수 테스트가 진행될 수록 테스트마다 준비하는 요청이 중복되는게 많아지고 그에따라 시간도 많이 소비된다.
이를 해결하기 위해 새로운 방식이 언급되었는데 그건 바로 인수 테스트 통합이다.
Feature: 즐겨찾기를 관리한다.
Background
Given 지하철역 등록되어 있음
And 지하철 노선 등록되어 있음
And 지하철 노선에 지하철역 등록되어 있음
And 회원 등록되어 있음
And 로그인 되어있음
Scenario: 즐겨찾기를 관리
When 즐겨찾기 생성을 요청
Then 즐겨찾기 생성됨
When 즐겨찾기 목록 조회 요청
Then 즐겨찾기 목록 조회됨
When 즐겨찾기 삭제 요청
Then 즐겨찾기 삭제됨
/**
* When 즐겨찾기 생성을 요청
* Then 즐겨찾기 생성됨
* When 즐겨찾기 목록 조회 요청
* Then 즐겨찾기 목록 조회됨
* When 즐겨찾기 제거 요청
* Then 즐겨찾기 제거됨
*/
@DisplayName("즐겨찾기를 관리한다.")
@Test
void menageFavorite() {
// When
ExtractableResponse<Response> createResponse = 즐겨찾기_생성_요청(accessToken, 강남역, 삼성역);
// Then
assertThat(createResponse.statusCode()).isEqualTo(HttpStatus.CREATED.value());
// When
ExtractableResponse<Response> getResponse = 즐겨찾기_목록_조회_요청(accessToken);
// Then
assertThat(getResponse.statusCode()).isEqualTo(HttpStatus.OK.value());
// When
ExtractableResponse<Response> deleteResponse = 즐겨찾기_제거_요청(accessToken, createResponse.header("Location"));
// Then
assertThat(deleteResponse.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value());
}
하나씩 검증하다가 통합해서 검증하면 무엇이 좋을까?
- 테스트 비용 절감
- 중복을 효과적으로 제거
- 흐름 검증으로 자연스럽게 사용자 스토리에 대한 검증이 가능
그렇다면 통합테스트는 어떻게 작성할까? 2가지 방법이 있다.
1. 단건으로 진행한 후에 하나로 통합하면 된다.
2. 처음부터 통합으로 만들면 테스트 메서드로 한 스탭씩 진행하면서 검증한다.
인증 기반 인수 테스트를 진행하면서 토큰도 접하게 되었다. (이전까지는 세션 인증밖에 몰랐다.)
간단하게 세션은 서버에 상태를 저장하고, 토큰은 발급된 키를 가지고 주고받으며 계속 비교하기 때문에 서버에 저장이 되지 않는 차이점이 있다.
그리고 테스트에서만 사용되는 프로덕션 코드에 대한 주제도 있었다.
나도 예전 TDD와 지금 ATDD를 진행하면서 객체를 세팅하면 ID 같은 식별키를 직접 생성자에 넣어서 만들곤 했는데, 이는 테스트를 위해서 프로덕션에 테스트용 코드를 추가하게 된거다. 제한적인 범위내에서 이를 괜찮게 본다는 의견과
프로덕션 코드에 영향을 안끼치게 리플렉션을 이용해서 세팅해야한다는 의견이 있는데
과연 무엇이 더 옳은 방법일까? 고민을 한번 해본다.
그외에도 의존성에 대한 주제도 있는데
우아한 객체지향 세미나를 참고해보자.
정리할 내역
- 점진적 리팩터링 with 테스트
- 인증과 인증 도구(세션, 토큰)
- 스프링 시큐리티 인증 아키텍쳐
- 그외 등등.
'교육 및 인강 > ATDD, 클린 코드 with Spring' 카테고리의 다른 글
ATDD 5주차 - 마무리 (0) | 2022.07.25 |
---|---|
ATDD 4주차 - 테스트 기반 문서화 (0) | 2022.07.25 |
ATDD 2주차 - ATDD + TDD 후기 (0) | 2022.06.24 |
ATDD 1주차 - 인수테스트 후기📖 (0) | 2022.06.21 |
ATDD 시작하면서... (0) | 2022.06.09 |