테스트
개요
테스트는 크게 3개로 나뉩니다.
개별적인 로직을 검증하는 유닛 테스트와, 실 사용자가 수행하는 시나리오대로 흘러가는 것을 검증하는 E2E 테스트, 모듈 간의 호환성과 통합 과정을 검증하는 통합 테스트 등입니다.
개중에서도 유닛 테스트를, 특히 TestContainer를 기반으로 작성한 이야기를 해보려고 합니다.
유닛 테스트는 각 기능 단위에서 수행하는 로직이 무결함을 보여주는 것이 주된 목표입니다.
때문에 의존성이나 다른 코드에 의한 변동이 발생해서는 안됩니다. 가령, 가입한 사용자를 검증하는 로직을 테스트할 때, 다른 로직에서 회원가입을 진행하여 DB에 동일한 ID의 사용자가 있으면 테스트가 실패할 수 있습니다.
만일 중복 가입에 대한 테스트를 하고자 했던 것이 아니라면, 이는 올바른 테스트가 아닙니다(중복 가입을 테스트하고 싶었다고 했어도 테스트 내부에서 가입 로직을 두 번 추가하는 식으로 구성하는 것이 유닛 테스트의 의도에 조금 더 부합하다고 봅니다).
기존 방식
기존에는 기능을 개발하면 서버를 켜 Swagger로 매번 손수 테스트를 진행했습니다.
손수 하는 테스트는 매번 수정 후 서버를 다시 켜야만 하는 불편함을 넘어서 상당히 많은 사전준비를 필요로 합니다. 예시를 들어봅시다.
A라는 함수는 가입한 사용자의 리뷰를 등록하는 기능입니다. 이때, 상품 구매 후 3개월이 지나면 상품 리뷰를 등록한다고 해도 이를 비활성화시킬 것입니다.
다음 기능을 손수 테스트하려면 미리 이것들을 준비해야 합니다.
- 테스트 사용자로 가입하고 로그인합니다.
- 판매자 DB에 테스트 판매자의 정보를 등록하고, 로그인합니다.
- 상품 DB에 상품들을 등록합니다.
- 판매자 계정에서 로그아웃하고 다시 테스트 사용자로 로그인합니다.
- 사용자가 구매한 상품 DB에 추가해둔 상품 데이터를 등록합니다.
- 일부 상품의 구매 기간을 억지로 변경합니다(기능이 비활성화되었는지를 확인하기 위함입니다).
그리고 다음의 로직을 필요로 합니다.
- auth → 회원가입, 로그인, token refresh 등 가입과 인증에 관련한 처리.
- 상품 → 판매자 등록, 판매 상품 등록 등에 관련한 모든 처리.
- 검색 → 사용자가 실제로 상품을 찾는 방식에 대한 처리.
- 리뷰 등록 → 리뷰 등록 / 실제로 3개월 이후에 기능이 비활성화되는 것이 가능한지 확인하기.
만일 상품을 구매한 후 3개월이 지난 후에도 리뷰 기능이 비활성화되지 않는 오류를 발견했다고 합시다. 로그를 보고, 원인을 파악하고 로컬 서버를 끈 뒤 로직을 검증하고 다시 코드를 작성해야 할 것입니다.
로컬 서버를 다시 켠 후에는 1번부터 5번까지를 다시 수행해 테스트를 위한 환경을 일일이 구성해야 할 것이고, 만일 수정이 제대로 되지 않았다면 다시 서버를 끄고...로직을 수정하고...다시 서버를 켜고...swagger에 접속하고(혹은 postman을 쓰고)...1번부터 5번까지의 로직을 수행해야 할 것입니다.
그리고 1주일 후에서야 리뷰 등록에 대한 예외 처리가 더 필요했다는 것을 깨닫습니다. 다시 수정을 시작해보니 그간 상품에 대한 메소드들이 변경되면서 리뷰 등록이 제대로 되고 있지 않았습니다. 그 이후로 리뷰 쪽을 만질 일이 없었다보니 몰랐던 것입니다.
당연하지만 리뷰 등록과 수정과 삭제 모든 기능이 상품 메소드 변경에 따른 에러를 뱉고 있었고, 매 기능을 고칠 때마다 1번부터 5번까지의 환경 구성에는 영겁과도 같은 시간이 걸리듯이 느껴질 것입니다.
현실로 돌아와서
지나친 비약과 어폐로 점철된 이야기가 프로파간다가 되기 전에 빠져나옵시다.
저렇게 되기 전에 누군가 상품의 메소드에 리뷰가 의존적이라는 것을 알았을 것이고, 미리 조치를 취했을 수도 있고, 혹은 예외 처리가 더 필요하지 않았을 수도 있었을 것입니다. 하지만 누군가 알아차리지 않아주는 한 평생 그대로인 코드보다는 검증 가능한 코드가 훨씬 좋겠죠.
테스트를 작성하기 시작하면 이전에 제시된 대부분의 문제를 해결할 수 있게 됩니다.
- 코드를 테스트해야 하는 환경에 준비해야 하는 기능이 많다면, 테스트를 통해 이를 사전에 처리할 수 있습니다. 일반적으로 같은 도메인으로 묶인 기능들은 대개 준비해야하는 환경이 비슷합니다.
- 검증 과정은 단순히 성공과 실패로 갈리지 않고 그 케이스에 따라 면밀한 결과 여부를 확인해야만 합니다. 테스트는 각 경우에 따른 성공과 실패를 제공하기 위해 많은 함수를 제공하며, 로직이 변경되어 의도하지 않은 값이 나오면 개발자가 즉각적으로 알 수 있게끔 해줍니다.
고작 이유가 두 개인가 싶으시겠지만, 이번 테스트 코드의 진면모는 뒤에서 다시 언급될 것입니다. 일단 어떤 방식을 사용하고 있었는지에 대한 이야기를 좀 해볼게요.
테스트 컨테이너
매 순간마다 도커에서 이미지를 빌드하고 소멸시키며 무결한 테스트 환경을 구성하는 것이 특징입니다.
H2와 같이 DB를 모방하는 것이 아니라 실제 DB의 이미지를 가져오는 것이므로 보다 정확한 테스트가 가능해집니다.
테스트는 성공/실패하는 경우를 모두 작성합니다. 만일 실패한다면 어디에서 실패할 것인지, 성공한다면 어떤 조건이 충족되어야 하는지가 모두 적혀있어야 한다는 전제를 두고 작성했습니다.
@SpringBootTest
@Slf4j
@ActiveProfiles("test")
@DisplayName("Jwt Util Component에 관한 테스트입니다.")
public class JwtUtilTests {
@Autowired
private JwtUtil jwtUtil;
@DisplayName("적절한 토큰과 시크릿을 인자로 보낼 때 jwt 내부의 클레임을 확인해주어야 합니다.")
@Test
public void parseBasicTokenTest() {
final String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywiZW1haWwiOiJqb2huQGdtYWlsLmNvbSIsImFkbWluIjpmYWxzZX0.aNg-st9XEAWSA2sZu9o-2DWZOszSDyEWx8ChIlE1iug";
//jwt.io에서 생성하여 가져온 mock 값.
final Long uid=123L;
final String email = "john@gmail.com";
final Boolean admin = false;
final String secret = "secret";
//실제 값.
JwtParseResult result = jwtUtil.parse(token, secret);
Assertions.assertNotNull(result);
Assertions.assertTrue(result.isSuccess());
TokenClaimsDTO claims = result.getClaims();
Assertions.assertNotNull(claims);
Assertions.assertEquals(uid, claims.getUid());
Assertions.assertEquals(email, claims.getEmail());
Assertions.assertEquals(admin, claims.getAdmin());
}
@DisplayName("적절하지 못한 시크릿이 삽입되어 Invalid Signature과 관련한 exception을 반환해야 합니다.")
@Test
public void parseNotValidSecretToken() {
final String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsImVtYWlsIjoiYWRtaW5AYWRtaW4uY29tIiwiYWRtaW4iOnRydWV9.Anzs8j7SajasVLq9_HdJSnIcXM3Ces3q7X2bBGrNiOg";
//jwt.io에서 생성하여 가져온 mock 값.
final Long uid=1L;
final String email = "admin@admin.com";
final Boolean admin = true;
final String secret = "secret";
//실제 값.
Assertions.assertThrows(SignatureException.class, () -> {
jwtUtil.parse(token, "invalid");
});
}
@DisplayName("적절하지 않은 토큰을 삽입했을 때 에러를 반환해야 합니다.")
@Test
public void invalidTokenValueTest() {
final String token = "tokentestvalue";
//무작위 string 값
final Long uid=1L;
final String email = "admin@admin.com";
final Boolean admin = true;
final String secret = "secret";
Assertions.assertThrows(MalformedJwtException.class, () -> {
jwtUtil.parse(token, "secret");
});
}
@Testcontainers(disabledWithoutDocker = true)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Transactional
@Slf4j
public class AuthControllerTests extends SpringMockMvcTestSupport {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
private SignUpRequestDTO signUpRequestDTO() throws Exception {
final SignUpRequestDTO signUpRequestDTO = new SignUpRequestDTO();
signUpRequestDTO.setMemberId("test@test.com");
signUpRequestDTO.setMemberName("테스트");
signUpRequestDTO.setMemberPwd("test");
signUpRequestDTO.setAgreeTerm("true");
signUpRequestDTO.setIntroduceUrl("<https://github.com/test>");
signUpRequestDTO.setSpecInfo("1");
signUpRequestDTO.setUploadFile(null);
return signUpRequestDTO;
}
private RequestBuilder signUpRequestBuilder() throws Exception {
SignUpRequestDTO signUpDTO = this.signUpRequestDTO();
MockMultipartFile file = new MockMultipartFile(
"UploadFile",
"00.jpeg",
MediaType.IMAGE_JPEG_VALUE,
"test".getBytes()
);
RequestBuilder builder = MockMvcRequestBuilders.multipart("/member/auth/sign-up")
.file(file)
.param("memberId", signUpDTO.getMemberId())
.param("memberPwd", signUpDTO.getMemberPwd())
.param("memberName", signUpDTO.getMemberName())
.param("agreeTerm", signUpDTO.getAgreeTerm())
.param("introduceUrl", signUpDTO.getIntroduceUrl())
.param("specInfo", signUpDTO.getSpecInfo())
.contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
.characterEncoding(StandardCharsets.UTF_8)
.accept(MediaType.APPLICATION_JSON);
return builder;
}
@DisplayName("기본적인 회원가입 동작 테스트")
@Test
public void signUpTest_Basic() throws Exception {
SignUpRequestDTO signUpRequestDTO = this.signUpRequestDTO();
MvcResult mvcResult = this.mockMvc.perform(this.signUpRequestBuilder())
.andExpect(MockMvcResultMatchers.status().isCreated())
.andDo(print()).andReturn();
CommonResponseDTO response = this.objectMapper.readValue(mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8), CommonResponseDTO.class);
Assertions.assertNotNull(response);
Assertions.assertEquals("OK", response.getMessage());
Cookie jwt = mvcResult.getResponse().getCookie("jwt");
String claims = jwt.getValue();
Assertions.assertNotNull(jwt);
}
@DisplayName("로그인 확인")
@Test
public void SignInTest_Basic() throws Exception {
...
RequestBuilder signInRequestBuilder = MockMvcRequestBuilders.post("/member/auth/sign-in")
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsBytes(signIn));
MvcResult mvcResult = this.mockMvc.perform(signInRequestBuilder)
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(print()).andReturn();
SignInResponseDTO response = this.objectMapper.readValue(mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8), SignInResponseDTO.class);
Assertions.assertNotNull(response);
Assertions.assertEquals(signUp.getMemberId(), response.getEmail());
Assertions.assertEquals(signUp.getMemberName(), response.getMemberName());
Cookie jwt = mvcResult.getResponse().getCookie("jwt");
String claims = jwt.getValue();
Assertions.assertNotNull(jwt);
}
@DisplayName("로그아웃")
@Test
public void SignOutTest() throws Exception {
RequestBuilder signOutRequestBuilder = MockMvcRequestBuilders.get("/member/auth/sign-out")
.contentType(MediaType.APPLICATION_JSON);
MvcResult mvcResult = this.mockMvc.perform(signOutRequestBuilder)
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(print()).andReturn();
Assertions.assertNotNull(mvcResult.getResponse().getCookie("jwt"));
}
}
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Slf4j
@ActiveProfiles("test")
@Transactional
public class AuthServiceTests {
@Autowired
private AuthService authService;
@Autowired
private TestHelper testHelper;
private SignUpRequestDTO signUpRequestDTO() {
final SignUpRequestDTO signUpRequestDTO = new SignUpRequestDTO();
MockMultipartFile file = new MockMultipartFile(
"image",
"test.png",
MediaType.IMAGE_PNG_VALUE,
"test.png".getBytes(StandardCharsets.UTF_8)
);
signUpRequestDTO.setMemberId("test");
signUpRequestDTO.setMemberName("테스트");
signUpRequestDTO.setMemberPwd("test");
signUpRequestDTO.setAgreeTerm("true");
signUpRequestDTO.setIntroduceUrl("<https://github.com/test>");
signUpRequestDTO.setSpecInfo("1");
signUpRequestDTO.setUploadFile(file);
return signUpRequestDTO;
}
@DisplayName("회원 가입 성공")
@Test
public void createUserRequestSuccess() {
SignUpRequestDTO signUpRequest = this.signUpRequestDTO();
Assertions.assertTrue(this.authService.signUp(signUpRequest).getEmail().equals(signUpRequest.getMemberId()));
}
@DisplayName("회원 가입 실패 - 중복 가입")
@Test
public void createUserRequestDuplicated() {
...
Assertions.assertThrows(RuntimeException.class, () -> {
this.authService.signUp(signUpRequest);
});
}
@DisplayName("로그인 성공")
@Test
public void loginUserRequestSuccess() {
...
Member signInMember = this.authService.findMemberMatchedPassword(signInRequest);
Assertions.assertNotNull(signInMember);
Assertions.assertEquals(signInMember.getEmail(), signUpRequest.getMemberId());
Assertions.assertEquals(signInMember.getPwd(), signUpRequest.getMemberPwd());
Assertions.assertEquals(signInMember.getMemberName(), signUpRequest.getMemberName());
}
@DisplayName("로그인 실패 - 존재하지 않는 계정")
@Test
public void loginUserRequestNotExist() {
...
Assertions.assertThrows(RuntimeException.class, () -> {
this.authService.findMemberMatchedPassword(signInRequest);
});
}
@DisplayName("로그인 실패 - 비밀번호 올바르지 않음")
@Test
public void loginUserRequestDuplicated() {
...
Assertions.assertThrows(RuntimeException.class, () -> {
this.authService.findMemberMatchedPassword(signInRequest);
});
}
}
단점
1. 테스트 수행 기간이 길어짐
유닛 테스트는 단일한 로직을 수행하는 짧고 빠른 형태라는 점이 강점이자 가장 중요한 점입니다. 하지만 테스트 컨테이너를 사용하면 테스트 시간이 굉장히 길어집니다.
로직 수행 -> 종료 였던 유닛 테스트의 로직이
이미지를 로드해 테스트 컨테이너 생성 -> autoDDL or initScript 실행으로 환경 구성 -> 로직 수행 -> 테스트 컨테이너 소멸 이라는 긴 과정을 거쳐야 하기 때문입니다.
특히 저희 같은 경우에는 많은 테이블과 관계가 요구되기 때문에 init script를 사용하는 대신 autoDDL을 사용하기로 했습니다. 때문에 JDBC에 의존적인 테스트 컨테이너를 만들었는데, 이렇게 되면 실제로 jdbc에 명시된 DB를 사용하지 않더라도(가령, util 등) @TestContainer 어노테이션을 사용하면 자동으로 컨테이너를 생성하고 autoDDL을 수행하기 때문에 역으로 테스트 컨테이너가 타 유닛 테스트 작성에 부정적인 영향을 주는 케이스도 있었습니다.
때문에 테스트 코드 중심적인 개발엔 어려움이 있는 것으로 보였습니다.
그래서 그냥 의존성이 있는 코드가 아니면 그냥 테스트 컨테이너를 걷어내고 쓰는게 나을 수도 있겠다는 생각이 드는데, 사실 그쯤되면 이거...쓰는게 맞나?🤨 란 의심을 할 수밖에 없어지죠...
2. 환경을 구성할 때 정확한 니즈를 충족하기 어려움
테스트 컨테이너는 코드상에서 새로운 객체로 생성하는 방법과 JDBC에 의존적인 형태로 구성하여 테스트 코드 실행시마다 자동으로 컨테이너를 생성하고 소멸하게 하는 두 가지 방법을 가지고 있습니다.
둘 다 사용하거나 하나를 생성하되 다른 곳에서 연결하는 형태로 만들 수는 없고 하나를 사용하면 하나는 사용해선 안 됩니다(처음에 이걸 모르고 작업을 하면서 왜 컨테이너가 두 개나 떠있으며 하나는 init이 안 되어있고 하나는 처음부터 끝까지 한 번도 안 쓰이는거지 싶었습니다).
객체를 코드에서 생성하는 방법은 다음과 같은 장단점을 가집니다.
- 상대적으로 커스터마이징이 좀 더 편함
- 생성 주기를 조절할 수 있음(가령, 매번 생성하고 소멸시키는 등 유동적인 관리가 가능해짐)
- 매번 객체를 선언해야 하므로 불필요한 코드가 늘어날 수 있음.
그리고 JDBC에 의존적인 형태로 자동 생성되는 방법은 다음과 같은 장단점을 가집니다.
- 세션 팩토리가 생성되는 시점에 생겨나므로 JDBC의 AutoDDL을 이용할 수 있음.
- 상대적으로 커스터마이징이 어려움
- 생성 주기를 관리하는데 제약이 있어 **Transactional**을 사용하지 않으면 안됨. (이걸로도 관리되지 않는 코드는 사용에 한계점이 있음)
노란 부분이 실질적으로 원하던 부분이었지만 컨테이너의 환경보다는 엔티티가 변동될 확률이 높다는 전제로 후자를 선택하게 되었습니다.
도입할 때 매번 컨테이너의 생성과 소멸이 가능한 게 특장점이었던 환경이니만큼, 매 로직마다 롤백을 하는 구조만큼은 피하고 싶었지만 JDBC에 의존한 자동 컨테이너는 static하게 유지되어 각 테스트마다 현상태를 유지하기 때문에(또한 컨테이너를 생성하고 소멸시키는 시간이 실제 테스트 시간보다 긴 경우가 상당히 많았기 때문에 매 테스트마다 그 영겁같은 시간을 견디는 게 너무 피곤했기 때문에) 사용할 수밖에 없게 되었습니다.
또한 도커 이미지의 장점인 yml 문서를 활용한 커스터마이징이 jdbc 환경에서는 어려우므로 만일 encoding이나 타임존과 관련한 로직 등이 생기면 일일이 sql문을 작성해 jdbc에 init script로 삽입해줘야만 합니다.
장점
1. 그럼에도 불구하고 편함
사실 DB가 유달리 불편스럽게 느껴졌던 것이지 redis 같은 다른 의존성은 오히려 편안하게 작업할 수 있습니다. 세션 팩토리의 생성 주기에 의존적인 JDBC의 auto-ddl과 커스터마이징의 한계에서 갈등하던 것과는 달리 레디스의 동작은 굉장히 매끄럽게 되었습니다(물론 테스트 시간이 많이 든다는 고질적인 한계는 극복할 수 없었지만요).
또한 docker-compose를 활용하는 것도 가능하기 때문에 여러 이미지에 대한 관리도 용이하고, utf-8 적용과 같은 문제도 이미지 설정을 통해 어느 정도 해결이 가능합니다.
테스트에 대한 부정적인 인식이 아니라면, 매번 컨테이너 데이터들을 지우고...데이터베이스를 드랍하고...문제가 생기면 재생성하는 행동보다는 는
2. LocalStack과 같은 외부 의존성에 대한 어느 정도의 해결
LocalStack은 일종의 Mock 객체입니다. 차이점이 있다면 철저히 AWS를 기반으로 하고 있다는 점입니다. S3, SQS(지만 저희 SQS는 엘라스틱 서치 등의 의존 문제 등이 있음) 등의 외부 서비스를 테스트 시 가상의 형태로 이용해볼 수 있게 됩니다.
public Optional<String> imageUploader(MultipartFile file) {
String contentType = file.getContentType().split("/")[1];
if(isContentTypeImage(contentType)) {
throw new RuntimeException("Invalid File Content Type.");
}
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(contentType);
metadata.setContentLength(file.getSize());
String dir = this.getS3ImageDir(contentType);
if(dir.isEmpty()) {
throw new RuntimeException("이미지 삽입에 실패했습니다.");
}
try {
amazonS3Client.putObject(new PutObjectRequest(BUCKET_NAME, dir, file.getInputStream(), metadata));
log.info("S3 Image Created");
return Optional.of(BUCKET_ADDRESS+"/"+dir);
} catch (IOException e) {
e.printStackTrace();
}
return Optional.empty();
}
@Testcontainers
@Slf4j
public class LocalStackTestcontainerTest {
@Container
LocalStackContainer localStackContainer = new LocalStackContainer()
.withServices(LocalStackContainer.Service.S3);
@Test
void localStackCreateWorked() throws Exception {
AmazonS3 s3 = AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(LocalStackContainer.Service.S3))
.withCredentials(localStackContainer.getDefaultCredentialsProvider())
.build();
String bucketName = "test-s3";
s3.createBucket(bucketName);
s3.putObject(bucketName, "key", "content");
System.out.println(s3.getBucketLocation(bucketName));
assertTrue(s3.doesBucketExistV2(bucketName));
}
테스트 컨테이너를 사용하면 이 LocalStack의 생성과 소멸 관리가 편리해집니다.
리팩토링
시작
남이 짠 코드를 만지기 무서우신 경험 있으신가요? 언제 어떻게 생긴지도 모르는 코드 뜯어고칠 생각에 숨막히신 적 있나요? 센 척 안 하고 고백하겠습니다만 전 있습니다... 저만 그랬을 수도 있으니 철저히 자기중심적으로 이야기하겠습니다.
리팩토링이 섵불리 못하는 이유는 기존 코드가 어떤 의존성을 가지고 있으며, 어떤 의도를 가졌는지, 어떤 의의와 목적을 가지는 지에 대해 전부 알기 힘들며, 만일 안다고 해도 개발 도중 그 모든 규약을 지키면서 할 수 없을 거라는 두려움에서 기인한다고 생각합니다. 아무튼 저는 그렇습니다.
이걸 테스트가 많이 극복시켜줬습니다. 단순히 코드의 로직만 보고 코드를 개선한다는 건 상당한 불안이 밀려오지만, 이젠 그 지반을 테스트로 잘 닦아뒀기 때문에 개발 도중 의도상으로 어긋난 부분이 있다면 테스트가 알려줄 것이고, 문제가 없다면 기존에 통과하던 테스트가 로직 변경 후에도 통과되었으니 나름 잘 되었으리라는 믿음이 따라오니까요.
테스트 코드 작성 이후 처음으로 한 리팩토링은 사용자의 로그인과 관련한 부분입니다.
그 부분의 리팩토링에 대한 의도와...보수와...여전히 어떤 점이 개선안으로 남아있는지에 대한 이야기를 해보겠습니다.
기존 jwtUtil
리팩토링 후 jwtUtil
코드가 대단히 줄어든 것처럼 보이지만, 실제론 다른 곳으로 분리가 된 것 뿐입니다.
지금은 어떤 부분을 고치는 게 좋을 것 같다고 생각했는지부터 시작합시다.
그 많던 코드는 어디로 갔을까? 의 의문은 뒤에서 나오게 될 것입니다.
예시
코드의 흐름은 다음과 같습니다. 기존의 사항들은 다음으로 이어질 수 있습니다.
- JWT의 생성은 Member 엔티티에 의존적입니다. 어드민 페이지에서도 JWT를 생성해야 하며, 동일한 인자(uid, email, admin 체크 등)가 들어감에도 불구하고 동일한 메소드로 JWT를 생성할 수는 없습니다.
public String **makeJwtToken**(SignInVo member) { log.info("jwt Component - member"); log.info(member.toString()); return Jwts.builder() .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // (1) .setIssuer(ISSUER) // (2) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + JWT_EXP)) .claim("uid", member.getMember().getUid()) // (5) .claim("email", member.getMember().getEmail()) .claim("admin", false) .signWith(SignatureAlgorithm.HS256, SECRET) // (6) .compact(); } public String **setAdminJwt**(UserJwtDTO member) { log.info("jwt Component - admin"); log.info(member.toString()); return Jwts.builder() .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // (1) .setIssuer(ISSUER) // (2) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + JWT_EXP)) .claim("uid", member.getUid()) // (5) .claim("email", member.getEmail()) .claim("admin", true) .signWith(SignatureAlgorithm.HS256, SECRET) // (6) .compact(); }
- @ApiOperation(value = "회원가입") @PostMapping(value = "/sign-up", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity signUp(@ModelAttribute SignUpRequestDTO signUpRequestDTO) { SignUpVo signUpVo = authService.signUp(signUpRequestDTO); if ("OK".equals(signUpVo.getCommonResponseDTO().getMessage()) && signUpVo.getMember().getUid() > 0) { SignInVo member = new SignInVo(); member.setMember(signUpVo.getMember()); member.setResult("OK"); String jwtToken = **jwtUtil.makeJwtToken(member)**; ResponseCookie responseCookie = ResponseCookie.from(COOKIE_NAME, jwtToken) .secure(SECURE) .path(PATH) .maxAge(MAX_AGE) // 7일짜리 .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) .body(signUpVo.getCommonResponseDTO()); } return ResponseEntity.ok().body(signUpVo.getCommonResponseDTO()); }
- @ApiOperation("관리자 로그인") @PostMapping("/sign-in") public ResponseEntity AdminSignIn(@RequestBody AdminSignInRequestDTO adminSignInRequestDTO) { AdminMember adminMember = this.adminMemberService.adminSignIn(adminSignInRequestDTO); if(adminMember.getUid() > 0) { UserJwtDTO userJwtDTO = new UserJwtDTO(); userJwtDTO.setUid(adminMember.getUid()); userJwtDTO.setEmail(adminMember.getAdminEmail()); String jwtToken = **jwtUtil.setAdminJwt(userJwtDTO)**; ResponseCookie responseCookie = ResponseCookie.from(COOKIE_NAME, jwtToken) .secure(SECURE) .path(PATH) .maxAge(MAX_AGE) // 7일짜리 .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) .body(this.modelMapper.map(adminMember, AdminSignInResponseDTO.class)); } return ResponseEntity.internalServerError().body("Cannot login");
- JWT는 accessToken과 refreshToken으로 구성됩니다. 각각의 토큰에는 들어가야하는 정보값이 다르지만 들어가는 인자(클레임)가 static하게 구성되어 있으므로 각 토큰을 생성할 때는 별도의 메소드를 활용해야만 할 것입니다.
- 쿠키를 컨트롤러에서 매번 선언하며 생성하고 있습니다. 쿠키에는 max-age나 path 와 같은 환경변수 값들이 사용되기 때문에 컨트롤러에서 실질적으로 필요한 값이 아님에도 불구하고 환경변수 상수값들이 코드 최상단에 마구잡이로 선언되어야만 합니다. 또한 중복적인 코드가 지나치게 많습니다.
- @ApiOperation(value = "회원가입") @PostMapping(value = "/sign-up", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity signUp(@ModelAttribute SignUpRequestDTO signUpRequestDTO) { SignUpVo signUpVo = authService.signUp(signUpRequestDTO); if ("OK".equals(signUpVo.getCommonResponseDTO().getMessage()) && signUpVo.getMember().getUid() > 0) { SignInVo member = new SignInVo(); member.setMember(signUpVo.getMember()); member.setResult("OK"); String jwtToken = jwtUtil.makeJwtToken(member); ResponseCookie responseCookie = ResponseCookie.from(**COOKIE_NAME**, jwtToken) .secure(**SECURE**) .path(**PATH**) .maxAge(**MAX_AGE**) // 7일짜리 .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) .body(signUpVo.getCommonResponseDTO()); } return ResponseEntity.ok().body(signUpVo.getCommonResponseDTO()); }
- 메소드의 역할이 명확히 분리되지 않았습니다. 하나의 메소드가 여러 행위를 수행하고 있었으며, 불필요한 모듈들을 호출하고 있습니다. 각 로직이 필요한 경우가 생기면 사실 이 친구의 기능을 분리하는 것보다는 매번 메소드를 생성하는 게 오히려 편하게 느껴질 수도 있을 것입니다(좋은 방법이 아니지만요).
public ParseJwtMemberDTO parseJwtToken() {
ParseJwtMemberDTO returnData = new ParseJwtMemberDTO();
try {
**HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String cookieValue = cookieUtilComponent.getCookieValue(request, "jwt");**
if (cookieValue == null) {
return returnData;
}
String[] tokenArray = cookieValue.split("\\\\.");
**if (tokenArray.length > 0) {
Base64.Decoder decoder = Base64.getDecoder();
String payload = new String(decoder.decode(tokenArray[1]));
ParseJwtMemberDTO parseMember = this.objectMapper.readValue(payload, ParseJwtMemberDTO.class);
returnData.setUid(parseMember.getUid());
returnData.setEmail(parseMember.getEmail());
returnData.setAdmin(parseMember.getAdmin());
returnData.setIat(parseMember.getIat());
returnData.setExp(parseMember.getExp());
returnData.setIss(parseMember.getIss());
return returnData;
}**
} catch (Exception e) {
e.printStackTrace();
}
return returnData;
}
//decode token
public ParseJwtMemberDTO decodeToken(String jwt) {
ParseJwtMemberDTO returnData = new ParseJwtMemberDTO();
try {
String[] tokenArray = jwt.split("\\\\.");
**if (tokenArray.length > 0) {
Base64.Decoder decoder = Base64.getDecoder();
String payload = new String(decoder.decode(tokenArray[1]));
ParseJwtMemberDTO parseMember = this.objectMapper.readValue(payload, ParseJwtMemberDTO.class);
returnData.setUid(parseMember.getUid());
returnData.setEmail(parseMember.getEmail());
returnData.setAdmin(parseMember.getAdmin());
returnData.setIat(parseMember.getIat());
returnData.setExp(parseMember.getExp());
returnData.setIss(parseMember.getIss());
return returnData;
}** else {
return returnData;
}
} catch (Exception e) {
e.printStackTrace();
}
return returnData;
}
개선 방안
- JWT를 생성할 때 무조건 그 인자를 알아야 할까요? 클레임의 내용을 일일이 가져와 생성하는 것이 jwt를 발급하는 메소드의 책임이 되어야할까요? 만일 외부에서 클레임을 생성해서 반환할 수 있다면, refreshToken과 accessToken에 대하여 별도의 메소드를 생성하지 않아도 될 것입니다. 중복 코드는 줄어들고, 내부의 값이 변해도 다른 코드를 전부 수정할 필요도 없을 것입니다.
그리고 관리자와 일반 사용자 모두 하나의 함수로 jwt를 생성할 수도 있을 것입니다.
- 쿠키를 컨트롤러에서 일일이 생성해도 괜찮을까요? 만일 쿠키의 값 일부가 수정이 된다면 다른 컨트롤러의 쿠키 생성 코드를 매번 수정해야 할 것입니다. 가입과 로그인 모두 똑같은 코드를 쓰고 있지만, 만일 이걸 하나라도 놓친다면 인증이 필요한 모든 로직이 에러를 반환하게 될 것입니다.
- 컨트롤러에 환경 변수가 얹혀있어야 하는 이유가 있을까요? 가령, 컨트롤러가 쿠키의 이름을 알고 있어야만 하는 이유가 있을까요? 컨트롤러의 책임은 비즈니스 로직에서 반환된 결과값을 정확하게 반환하는 것에 있습니다.
실현
토큰 발급하기
public String makeJwtToken(SignInVo member) {
log.info("jwt Component - member");
log.info(member.toString());
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // (1)
.setIssuer(ISSUER) // (2)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + JWT_EXP))
.claim("uid", member.getMember().getUid()) // (5)
.claim("email", member.getMember().getEmail())
.claim("admin", false)
.signWith(SignatureAlgorithm.HS256, SECRET) // (6)
.compact();
}
public String issueToken(String secret, Long expiresAt, String issuer, Claims claims) {
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(issuer)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(expiresAt))
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
public String issueToken(String secret, Long expiresAt, String issuer) {
Claims claims = Jwts.claims();
return this.issueToken(secret, expiresAt, issuer, claims);
}
public String issueToken(JwtDescription jwtDescription) {
Long expiredAt = System.currentTimeMillis()+jwtDescription.getExpirationTerm();
return this.issueToken(jwtDescription.getSecret(), expiredAt, jwtDescription.getIssuer(), jwtDescription.getClaims());
}
@Getter
@Builder
@Slf4j
public class JwtDescription {
private String issuer;
private Long expirationTerm;
private String secret;
private Claims claims;
public static JwtDescription make(BasicTokenConfig basicTokenConfig, AccessTokenInfoDTO memberInfo, Boolean admin) {
Map<String, Object> claimsPayload = new HashMap();
claimsPayload.put("uid", memberInfo.getUid());
claimsPayload.put("email", memberInfo.getEmail());
claimsPayload.put("admin", admin);
Claims claims = Jwts.claims(claimsPayload);
return JwtDescription.builder()
.issuer(basicTokenConfig.getIssuer())
.expirationTerm(basicTokenConfig.getExpiration())
.secret(basicTokenConfig.getSecret())
.claims(claims)
.build();
}
public static JwtDescription make(BasicTokenConfig basicTokenConfig, AccessTokenInfoDTO memberInfo) {
return JwtDescription.make(basicTokenConfig, memberInfo, false);
}
}
public interface BasicTokenConfig {
Long getExpiration();
String getIssuer();
String getSecret();
}
---
public class AccessTokenConfig implements BasicTokenConfig {
@Value("${external.jwt.exp}")
private Long expiration;
@Value("${external.jwt.issuer}")
private String issuer;
@Value("${external.jwt.secret}")
private String secret;
}
쿠키 생성하기
@ApiOperation(value = "회원가입")
@PostMapping(value = "/sign-up", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity signUp(@ModelAttribute SignUpRequestDTO signUpRequestDTO) {
SignUpVo signUpVo = authService.signUp(signUpRequestDTO);
if ("OK".equals(signUpVo.getCommonResponseDTO().getMessage()) && signUpVo.getMember().getUid() > 0) {
SignInVo member = new SignInVo();
member.setMember(signUpVo.getMember());
member.setResult("OK");
String jwtToken = **jwtUtil.makeJwtToken(member)**;
ResponseCookie responseCookie = ResponseCookie.from(COOKIE_NAME, jwtToken)
.secure(SECURE)
.path(PATH)
.maxAge(MAX_AGE) // 7일짜리
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, responseCookie.toString())
.body(signUpVo.getCommonResponseDTO());
}
return ResponseEntity.ok().body(signUpVo.getCommonResponseDTO());
}
@ApiOperation(value = "회원가입")
@PostMapping(value = "/sign-up", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity signUp(@ModelAttribute SignUpRequestDTO signUpRequestDTO) {
Member member = authService.signUp(signUpRequestDTO);
AccessTokenInfoDTO memberTokenInfo = this.modelMapper.map(member, AccessTokenInfoDTO.class);
JwtDescription jwtDescription = JwtDescription.make(accessTokenConfig, memberTokenInfo);
String jwt = jwtUtil.issueToken(jwtDescription);
ResponseCookie responseCookie = responseCookieCreator.**create**(accessTokenCookieConfig, jwt);
CommonResponseDTO commonResponseDTO = new CommonResponseDTO();
commonResponseDTO.setMessage("OK");
commonResponseDTO.setStatus(true);
return ResponseEntity.status(HttpStatus.CREATED)
.header(HttpHeaders.SET_COOKIE, responseCookie.toString())
.body(commonResponseDTO);
}
public class ResponseCookieCreator {
public ResponseCookie create(BasicCookieConfig cookieConfig, String value, Long maxAge) {
ResponseCookie cookie = ResponseCookie.from(cookieConfig.getCookieName(), value)
.secure(cookieConfig.getSecure())
.path(cookieConfig.getPath())
.httpOnly(cookieConfig.getHttpOnly())
.sameSite(cookieConfig.getSameSite())
.maxAge(maxAge)
.build();
return cookie;
}
public ResponseCookie create(BasicCookieConfig cookieConfig, String value) {
return this.create(cookieConfig, value, cookieConfig.getMaxAge());
}
public ResponseCookie createExpired(BasicCookieConfig cookieConfig) {
return this.create(cookieConfig, null, 0L);
}
}
토큰 내용 가져오기
public ParseJwtMemberDTO decodeToken(String jwt) {
ParseJwtMemberDTO returnData = new ParseJwtMemberDTO();
try {
String[] tokenArray = jwt.split("\\\\.");
if (tokenArray.length > 0) {
Base64.Decoder decoder = Base64.getDecoder();
String payload = new String(decoder.decode(tokenArray[1]));
ParseJwtMemberDTO parseMember = this.objectMapper.readValue(payload, ParseJwtMemberDTO.class);
returnData.setUid(parseMember.getUid());
returnData.setEmail(parseMember.getEmail());
returnData.setAdmin(parseMember.getAdmin());
returnData.setIat(parseMember.getIat());
returnData.setExp(parseMember.getExp());
returnData.setIss(parseMember.getIss());
return returnData;
} else {
return returnData;
}
} catch (Exception e) {
e.printStackTrace();
}
return returnData;
}
public JwtParseResult parse(String token, String secret) {
TokenClaimsDTO claims = this.modelMapper.map(Jwts.parser().setSigningKey(secret.getBytes()).parseClaimsJws(token).getBody(), TokenClaimsDTO.class);
JwtParseResult jwtParseResult = new JwtParseResult();
jwtParseResult.setSuccess(true);
jwtParseResult.setClaims(claims);
return jwtParseResult;
}
@Getter
@Setter
public class JwtParseResult {
private boolean success = false;
private TokenClaimsDTO claims;
}
---
@Data
public class TokenClaimsDTO {
private Long uid;
private String email;
private Boolean admin;
}
의의
- 일반적으로 jwt는 accessToken과 refreshToken으로 구성됩니다. accessToken에 실질적인 정보를 넣어두고 보안을 위해 짧은 시간을 설정한 뒤, refreshToken이 그 짧은 시간을 보완하기 위해 계속해서 accessToken을 재발급할 수 있게 도우는 거죠. 때문에 일반적으로 accessToken과 refreshToken은 다른 속성을 지닙니다. 이렇게 되면 들어가는 claim에 대한 제약이 적어지므로 두 개의 메소드를 생성해 각각 다른 토큰을 생성하는 방식으로 코드를 짜지 않아도 됩니다.
- 기존 코드는 클래스 내부 최상단에 환경 변수들이 즐비하게 늘어져 있었습니다. token과 cookie에 대한 생성이 컨트롤러에 의존적이었기 때문입니다. 역할이 명확히 분리되면서 더이상 컨트롤러는 환경변수를 필요로 하지 않게 되었습니다. 그럼 어디로 갔을까요? 그건 컨트롤러가 알아야 하는 내용이 아닙니다. 쿠키를 생성하는 메소드가 알아야 하는 내용이죠.
@Getter @AllArgsConstructor @RequiredArgsConstructor @Component public class AccessTokenConfig implements BasicTokenConfig { @Value("${external.jwt.exp}") private Long expiration; @Value("${external.jwt.issuer}") private String issuer; @Value("${external.jwt.secret}") private String secret; }
- public static JwtDescription make(BasicTokenConfig basicTokenConfig, AccessTokenInfoDTO memberInfo, Boolean admin) { Map<String, Object> claimsPayload = new HashMap(); claimsPayload.put("uid", memberInfo.getUid()); claimsPayload.put("email", memberInfo.getEmail()); claimsPayload.put("admin", admin); Claims claims = Jwts.claims(claimsPayload); return JwtDescription.builder() .issuer(basicTokenConfig.getIssuer()) .expirationTerm(basicTokenConfig.getExpiration()) .secret(basicTokenConfig.getSecret()) .claims(claims) .build(); } public static JwtDescription make(BasicTokenConfig basicTokenConfig, AccessTokenInfoDTO memberInfo) { return JwtDescription.make(basicTokenConfig, memberInfo, false); }
의심
보이는 곳만 이야기해도 대략 다음과 같은 의문이 있습니다.
- 관리자의 jwt와 일반 사용자의 jwt에 들어가는 속성이 언제까지 동일할까요? 지금은 하나의 DTO로 이 친구들을 붙잡아두고 있지만, 만일 jwt에 들어가는 클레임(속성)이 변경된다면 아마 일반 사용자에 의해 변경이 될 확률이 높습니다. 두 곳에서 이렇게 같은 함수를 참조해도 괜찮을까요? 전혀 다르게 분리되어야 하는 기능을 억지로 엉겨붙여놓고 있진 않을까요?
- SignOut 메소드같은 경우에는, 썩 바람직한 코드로 보이지는 않습니다. 오직 jwt 발급을 위해 존재하는 컴포넌트를 억지로 끌어와 사용하고 있기 때문입니다. 하지만 로그아웃 쿠키는 반드시 jwt 쿠키명과 속성을 동일하게 가져야 합니다. 달라야 하는 건 오직 max-age 뿐입니다(0이 되어야 바로 쿠키가 사라질 테니까요). 그렇다면 이걸 분리하는 게 맞을까요? 아니면 상속하는 식으로 바꿔야할까요? 아니면 그대로 둬야 할까요?
- 현재 JWTParseResult는 JWT issue token에서 사용되는 MemberTokenDTO(클레임 생성 시 사용)와 별개로 분리되어 있습니다. 사용자의 admin은 멤버 엔티티에서 가져오는 값은 아니기 때문입니다. 만일 이런 상태로 계속 분리되어 있다면, 언젠가 문제가 생기지 않을까요?
@Data
public class AccessTokenInfoDTO {
private Long uid;
private String email;
}
@Data
public class TokenClaimsDTO {
private Long uid;
private String email;
private Boolean admin;
}
그 외에도 자잘한 여러 고민이 있습니다.
주관적인 관점에서 작성될 수밖에 없다는 이야기는 하셨지만 이게 맞는건가? 이게 최선인가? 라는 고민이 계속 드는 것 같습니다.
후기
- 테스트 코드가 개발자에게 독립적이진 않고, 실제 코드보다 개발자의 의도를 반영하가 더 쉬울 뿐입니다. 테스트를 쓰면 예상한 에러를 방지할 수 있고 내가 의도한 로직이 의도한 대로 움직이는 것에 큰 도움을 주지만 애초에 적절한 검증 로직을 세밀하게 구성하지 않으면 의도하지 않은 방향으로 언제든지 코드가 튈 수 있습니다.
- 테스트 코드는 문서의 역할을 대체합니다. TestContainer를 사용할 때는 그 동작 방식 때문에 TDD를 하기에는 그다지 적합하지 않았지만 테스트 작성은 정확한 동작과 예외에 대한 명시를 제3자 입장에서도 보기 편리하게 해줍니다. 원래 로직을 짤 때 예외 처리 같은 걸 깜빡하거나 나중에서야 떠올라서 뒤늦게 수정하는 경우가 빈번하기 때문에 매번 노션에 투두리스트마냥 리스트를 좍좍 뽑아서 작업하곤 했는데 중간부터는 테스트로 어느 정도 대체가 가능해졌습니다.
기존 방식
@DisplayName("회원 가입 성공")
@Test
public void createUserRequestSuccess() {
SignUpRequestDTO signUpRequest = this.signUpRequestDTO();
Assertions.assertTrue(this.authService.signUp(signUpRequest).getEmail().equals(signUpRequest.getMemberId()));
}
@DisplayName("회원 가입 실패 - 중복 가입")
@Test
public void createUserRequestDuplicated() {
...
}
@DisplayName("로그인 성공")
@Test
public void loginUserRequestSuccess() {
...
Member signInMember = this.authService.findMemberMatchedPassword(signInRequest);
Assertions.assertNotNull(signInMember);
Assertions.assertEquals(signInMember.getEmail(), signUpRequest.getMemberId());
Assertions.assertEquals(signInMember.getPwd(), signUpRequest.getMemberPwd());
Assertions.assertEquals(signInMember.getMemberName(), signUpRequest.getMemberName());
}
@DisplayName("로그인 실패 - 존재하지 않는 계정")
@Test
public void loginUserRequestNotExist() {
...
final SignInRequestDTO signInRequest = new SignInRequestDTO();
signInRequest.setMemberId("user");
signInRequest.setMemberPwd("user");
Assertions.assertThrows(RuntimeException.class, () -> {
this.authService.findMemberMatchedPassword(signInRequest);
});
}
@DisplayName("로그인 실패 - 비밀번호 올바르지 않음")
@Test
public void loginUserRequestDuplicated() {
...
Assertions.assertThrows(RuntimeException.class, () -> {
this.authService.findMemberMatchedPassword(signInRequest);
});
}
- api 중에 단순한 CRUD(진짜로 단순한) 기능들이 몇 개 있는데, 이런 걸 테스트를 굳이 써야할까?... 라는 생각이 든 적 있습니다.기능을 위한 테스트가 아니라 커버리지를 위한 테스트가 되어버린 것이지 않나? 라는 생각을 하게 되어버리죠. 커버리지가 반드시 코드의 올바름을 증명하는 지표도 아닌데 말이에요.
- 지금까지 한 대부분의 service와 util의 분할, util의 역할과 책임을 명확히 나누고 조금 더 객체지향적인 형태로 바꾸는 것에서 가장 두드러지는 변화와 가시성을 보이는데 단순히 DB에서 가져오기 / DB에 넣기 / DB에서 지우기 만을 수행하는 기능들의 로직이 복잡한 것도 아니고 대단히 변경되어야만 하는 사항이 있는 것도 아니라면 테스트를 작성해야 하는가? 에 대한 생각...조금은 있습니다.
- 컨트롤러 테스트의 경우 유닛 테스트에서 많이 어긋나고 있다는 느낌을 받았습니다. 로직을 검사해야 하는데 서비스에 의존하지 않고서는 적절한 값을 받을 수 없기 때문입니다. Mock 서비스 객체를 만들어 사용하는 레퍼런스도 있었지만 어떤 식으로 하는 게 좋을지 아직 생각중입니다.
@DisplayName("로그인 확인")
@Test
public void SignInTest_Basic() throws Exception {
this.mockMvc.perform(this.signUpRequestBuilder())
.andExpect(MockMvcResultMatchers.status().isCreated())
.andDo(print()).andReturn();
SignUpRequestDTO signUp = this.signUpRequestDTO();
SignInRequestDTO signIn = new SignInRequestDTO();
signIn.setMemberId(signUp.getMemberId());
signIn.setMemberPwd(signUp.getMemberPwd());
RequestBuilder signInRequestBuilder = MockMvcRequestBuilders.post("/member/auth/sign-in")
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsBytes(signIn));
MvcResult mvcResult = this.mockMvc.perform(signInRequestBuilder)
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(print()).andReturn();
SignInResponseDTO response = this.objectMapper.readValue(mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8), SignInResponseDTO.class);
Assertions.assertNotNull(response);
Assertions.assertEquals(signUp.getMemberId(), response.getEmail());
Assertions.assertEquals(signUp.getMemberName(), response.getMemberName());
Cookie jwt = mvcResult.getResponse().getCookie("jwt");
String claims = jwt.getValue();
Assertions.assertNotNull(jwt);
}
- 3번에서 좀 더 확장이 돼서 테스트를 작성하면 리팩토링이 쉬워지는 것은 사실이지만 언제든지 허물어지기 쉬운 성은 아닐까? 하는 우려는 있습니다. 쓰는 입장에서 가치를 느끼지 못하면 코드 변경에 맞춰 테스트를 바꾸지 않을거고 테스트를 바꾸지 않으면 어느 시점부턴 실패하게 될 것이며 매번 실패하는 테스트는 더이상 코드의 무결성을 증명해주지 못하기 때문입니다. ← 그리고 실패하는 테스트 더미를 보고 난 후부터는 그에 대한 부정적인 경험만 남게 될 것입니다.
- 테스트 컨테이너를 아직은 쓰고 있지만 개발자 경험에서도 테스트에 부정적인 인상만 남게 될 것 같다는 걱정이 있습니다.
- 리팩토링을 하고는 있지만 지나친 확장이 되거나 반대로 지엽적인 개선으로 그칠까 하는 우려가 상당히 있습니다. OOP를 이해하는 데도(지금도 잘 모르지만) 아주 오랜 시간이 걸렸는데 OOP를 적용하는 건 생각보다 어려운 일이더라고요. 리팩토링...잘한다면 구조상으로는 깔끔하고 더 좋은 코드가 되겠지만 못하면 코드 부관참시가 되어버릴 수 있기 때문에 뭐 하나 수정하는데도 고민만 하고 있습니다...
'CONFERENCE' 카테고리의 다른 글
문서화에서 놓쳤던 몇 가지 부분들 (0) | 2023.08.05 |
---|---|
exception을 어떻게 관리해야 하는가 (0) | 2022.09.14 |