본문 바로가기
JAVA/오류기록

[회고] 2달간의 추가개발 프로젝트 진행하며(테스트코드, JPA Auditing)

by 진짠 2025. 11. 5.
728x90

테스트 작성 Arrange 단계에서의 변화

실무에서 커서AI의 도움을 받아 TDD를 진행하며 테스트 코드는 600개이상이 쌓였다. 늘어가는 테스트 코드 갯수와 전체 테스트 통과와 함께 보이는 초록불을 보고 있으면 뿌듯한 마음이 든다. 이전부터 느꼈지만 테스트를 하기 위해 사전준비해야 하는 단계(Arrange)는 크게 3단계로 나뉜다.

 

1. deleteAll

api를 실행하고 테스트용 db에 i/o 를 함께 테스트하기 때문에 사전에 db를 청소해주는 단계가 필요하다. 이 단계가 없으면 이전 테스트 코드에 의해 현재 테스트 코드의 결과값이 오염될 수 있다. 그런데 이 단계를 설정하는데 은근히 많은 비용이 든다. 

 

먼저 외래키 제약 조건을 고려해야 한다.

테스트 db는 실제 db와 동일한 환경으로 구성되어 있고 외래키 제약이 걸려있다. 외래키 제약이 걸려 있으면 외래키를 가지고 있지 않은 부모 테이블을 삭제할때 자식 테이블이 존재할 경우 삭제를 하지 못하도록 제약이 걸린다. 그래서 delete를 할때 순서를 항상 신경써줘야 한다.

 

사전 delete 해줘야 하는 테이블 갯수가 너무 많다.

나의 케이스를 설명드린다. 조회 api 테스트를 위해 템플릿을 생성하고(templateMajor), 그에 따른 하위 테이블들을 생성해야 한다.(templateMiddle, small, question, answer) 그리고 템플릿에 맞는 체크리스트들(checklistMaster, detail, file...) 들도 생성해야 한다. delete해야 할 테이블이 10개가 넘어가는 경우가 생긴다. 이걸 이전에는 이렇게 처리했다.

 

@Test
    void 체크리스트_개별_평가_조회_시_request_SUCCESS_반환(
        @Autowired ChecklistRepository checklistRepository,
        @Autowired TemplateMajorRepository templateMajorRepository,
        @Autowired ChecklistDetailRepository checklistDetailRepository,
        @Autowired TemplateQuestionRepository templateQuestionRepository,
        @Autowired TemplateAnswerRepository templateAnswerRepository
        .
        .
        .
    ) {
        // arrange
        checklistRepository.deleteAll();
        templateMajorRepository.deleteAll();
        checklistDetailRepository.deleteAll();
        templateQuestionRepository.deleteAll();
        templateAnswerRepository.deleteAll();
        .
        .
        .
    }

 

초기에 이렇게 구성한 이유는 메서드 단위에서 명확하게 어떤 의존 구조를 가지는지 알기 위해서였다. 하지만 모든 테스트 메서드마다 이런 과정이 반복되는것은 굉장히 비효율적이며 아래로 늘어지는 delete 구문에 어떠한 명확함도 얻을 수 없을 것이라 생각했다.

	@Autowired PlantRepository plantRepository;
    @Autowired SysCorporationRepository sysCorporationRepository;
    @Autowired ChecklistRepository checklistRepository;
    @Autowired ChecklistDetailRepository checklistDetailRepository;
    @Autowired ChecklistDetailFileRepository checklistDetailFileRepository;
    @Autowired TemplateMajorRepository templateMajorRepository;
    @Autowired TemplateMiddleRepository templateMiddleRepository;
    @Autowired TemplateSmallRepository templateSmallRepository;
    @Autowired TemplateQuestionRepository templateQuestionRepository;
    @Autowired TemplateAnswerRepository templateAnswerRepository;
    @Autowired ImprovementDetailRepository improvementDetailRepository;
    @Autowired ImprovementDetailFileRepository improvementDetailFileRepository;
    @Autowired SafePlanRepository safePlanRepository;
    @Autowired SafePlanDetailRepository safePlanDetailRepository;
    @Autowired SysCodeRepository sysCodeRepository;

    @BeforeAll
    void init() {
        testDataInit.init();
    }

    @BeforeEach
    void beforeEach() {
        deleteAll();
    }

 

다음과 같이 클래스 단위 의존구조로 변환하고 @BeforeEach 어노테이션을 사용하여 deleteAll() 과정을 거쳤다. 하나의 클래스가 굉장히 많은 의존성을 가지고 있지만 각 메서드에서는 필요한 사전 데이터를 저장하기만 하면 테스트가 가능해져서 가독성, 유지보수성에서 좋아졌다고 생각한다.

 

2. save

 

delete과정을 통해 db를 클린하게 만들었다면 다음은 사전 데이터를 db에 넣어놓는 작업이 필요하다. 이는 주로 조회 api에서 필요하다. 데이터가 있어야 조회를 해서 검증을 하기 때문이다. 이번엔 TestFixutre에 공통으로 사용중인 save 기능을 분류하여 재사용성을 높였다. 내가 하는 리팩토링 방법은 다음과 같다.

 

1. save 메서드를 생성

{
	DomainName domain = new DomainName();
    domain.setA("A");
    domain.setB("B");
    DomainName saveDomain = domainRepository.save(domain);
}

 

2. IDE의 extract method를 사용

private DomainName saveDomain() {
	DomainName domain = new DomainName();
    domain.setA("A");
    domain.setB("B");
    DomainName saveDomain = domainRepository.save(domain);
}

 

3. static 선언

private static DomainName saveDomain() {
	DomainName domain = new DomainName();
    domain.setA("A");
    domain.setB("B");
    DomainName saveDomain = domainRepository.save(domain);
}

 

4. IDE의 move method를 사용해 TestFixture class에 이동

 

다음과 같은 과정을 통해 TestFixutre에 등록된 메서드들은 모든 테스트 클래스에서 재사용이 가능하다. 이를 통해 사전 생성해야 하는 부분들을 비교적 간단하게 모듈화할 수 있었다.

 

3. 파라미터 세팅

 

파라미터 세팅은 @RequestParam, @ModelAttribute 어노테이션 사용 시 UrlComponentsBuilder를 통해 파라미터를 세팅하는 방법을 사용했다. 이 기능 사용 시 한글 검색값이 주어질 경우 인코딩 관련 에러가 발생했다. 영어가 아닌 외국어의 경우 인코딩 과정을 거쳐 서버에 전달되기 때문에 디코딩 과정이 필요하다. 내가 해결한 과정은 다음과 같다.

 

@Bean
public WebBindingInitializer customWebBindingInitializer() {
    return new ConfigurableWebBindingInitializer() {
    	super.initBinder(binder);
        
        @Override
        public void initBinder(WebDataBinder binder) {
            binder.registerCustomEditor(String.class, new PropertyEditorSupport() {
                @Override
                public void setAsText(String text) {
                    try {
                        setValue(URLDecoder.decode(text, StandardCharsets.UTF_8));
                    } catch (Exception e) {
                        setValue(text);
                    }
                }
            });
        }
    };
}

 

 

 

public interface WebBindingInitializer {

	/**
	 * Initialize the given DataBinder.
	 * @param binder the DataBinder to initialize
	 * @since 5.0
	 */
	void initBinder(WebDataBinder binder);

}

WebBindingInitializer 인터페이스는 웹에서 보낸 request 요청에 대한 데이터를 바인딩하는 WebDataBinder 를 파라미터로 받는다. 이것을 초기화하는 과정이 initBinder 메서드이다. 나는 이 기능을 오버라이딩 하여 바인딩할 때 UTF_8로 디코딩하는 기능을 추가했다. 

 

JPA auditing

시기가 너무 늦었다는 생각이 들었지만 JPA의 Auditing을 적용했다. Spring data JPA에서 엔티티가 언제 등록되고 언제 수정되었는지 자동으로 기록해주는 기술인데 거의 모든 테이블에 등록, 수정자와 등록, 수정일시가 들어가다보니 상당히 실무에서 유용한 기술이다. 왜 지금 적용했냐면 부끄럽지만 이런게 있는지도 몰랐기 때문이다. 인강을 들으며 알게 되었고 적용하는 것 자체는 별로 어렵지도 않았다.

 

먼저 해당 프로젝트 메인 클래스에 @EnableJpaAuditing 을 명시하여 해당 기능을 사용한다.

 

@SpringBootApplication
@EnableJpaAuditing
public class SafetyApplication {

    public static void main(String[] args) {
        SpringApplication.run(SafetyApplication.class, args);
    }

}

 

그리고 해당 기능을 사용할 도메인에 적용하는 클래스를 하나 만든다. 나의 경우는 BaseEntity로 명명했다.

 

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
    @CreatedBy
    @Column(name = "INSERT_USER", updatable = false, nullable = false)
    private String insertUser;

    @CreatedDate
    @Column(name = "INSERT_DT", updatable = false, nullable = false)
    private LocalDateTime insertDt;

    @LastModifiedBy
    @Column(name = "UPDATE_USER", nullable = false)
    private String updateUser;

    @LastModifiedDate
    @Column(name = "UPDATE_DT", nullable = false)
    private LocalDateTime updateDt;
}

 

다음과 같이 작성하게 되면 등록했을때 등록,수정자와 등록, 수정일시에 등록일이 표기된다. 그리고 수정을 하게 되면 updateUser, updateAt 데이터가 수정된 일시로 업데이트 된다. 그리고 적용할 대상 도메인에 상속하는 방식을 사용한다.

 

@Entity
@Table(name = "SAFE_PLAN")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class SafePlan extends BaseEntity {
.
.

 

마지막으로 등록, 수정자가 누군지 알기 위해서 AuditorAware<T> 의 구현체를 구현해줘야 한다.

 

public interface AuditorAware<T> {

	/**
	 * Returns the current auditor of the application.
	 *
	 * @return the current auditor.
	 */
	Optional<T> getCurrentAuditor();
}

 

해당 인터페이스는 다음과 같으며 구현 메서드가 하나이기 때문에 람다 표현식을 사용하여 구현이 가능하다.

 

@Configuration
public class AuditorConfig {
    @Bean
    public AuditorAware<String> auditorAware() {
        return () -> {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

            if (authentication == null || !authentication.isAuthenticated()) {
                return Optional.of("UNDEFINED");
            }

            return Optional.of(authentication.getName());
        };
    }
}

 

그래서 다음과 같이 spring security의 Authentication에서 값을 가져온다. 나의 경우에는 JwtToken 방식의 로그인을 사용하는데 이를 통해 토큰을 받아오고 JwtFilter 라는 클래스를 통해 Authentication을 세팅한다.

 

@Override
    public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

        String jwt = resolveToken(request);

        try {
            if (StringUtils.hasText(jwt) && jwtToken.validateToken(jwt)) {
                Authentication authentication = jwtToken.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
.
.
.

 

JwtFilter.class의 검증 부분인데 맨 마지막 검증을 통과하면 Authentication을 세팅해준다. 이 값을 통해 검증된 사용자의 정보를 알 수 있다.

 

그렇다면 가져오는 부분인 jwtToken.getAuthentication(jwt) 은 어떻게 구현되어 있는지 궁금할 수 있다.

 

public Authentication getAuthentication(String token) {
    Claims claims = Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();

    String username = claims.getSubject();

    return new UsernamePasswordAuthenticationToken(username, null, null);
}

 

다음과 같이 JwtToken을 파싱하여 가저온 claims 객체에서 getSubject()를 꺼내게 되는데 subject에 들어있는 데이터는 토큰을 생성할 당시에 넣어준 유저 아이디이다.

 

이를 통해 등록, 수정자를 자동으로 등록할 수 있었다.

 

느낀점

배움의 필요성을 더욱 느낀 기간이었다. 특히 JPA의 Auditing 기능은 JPA를 사용하는 회사라면 실무에서 너무나도 당연하게 사용하고 있을 기술 같았다. 하지만 프로젝트 내 백엔드 개발을 홀로 담당중인 환경에서는 담당자가 모르면 어떤 유용하고 좋은 기술일지라도 적용할 수 없다는 것이 치명적이었다. 따라서 실무에서 사용중인 기술이라면 좀 더 깊이있게, 혹은 다른 연관된 기술까지 같이 공부하는 것이 중요해보였다. 

 

테스트코드가 600개를 넘어갔다는 사실 또한 자축할 일이다. 단순히 갯수가 증가하는게 무슨 큰 의미가 있을까 싶지만 그래도 단순한 만큼 가장 잘 보이는 수치이니까 나름 혼자서 만족중이다. 하지만 코드의 퀄리티를 높이는 것은 또 하나의 챌린지가 되었다. 테스트 코드는 AI의 도움으로 빠르게 작성하기만 했는데 이게 변경사항이 생기면서 테스트 코드도 수정하는 일이 늘어나다보니까 작은 변화인데도 여러 군데를 수정하는 일이 비일비재했다. 그래서 좀 더 공통기능을 빼서 중복을 제거하고 간편하게 확인할 수 있도록 하는 일에 신경을 써야겠다. 

728x90

댓글