안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

밤이 늦었지만,  AOP에 관한 내용을 나눠서 정리하고자 글을 작성하게 되었습니다.

 

AOP를 알기 전에는, 핵심 로직과 부가 기능을 분리하지 않고 작성하다 보니, 추후 리팩토링 하는 과정에서 난관에 부딪친 적이 있습니다. 영한님 스프링 핵심 원리 고급편을 수강하고 나니, AOP를 활용하는 방법을 배울 수 있었고, 앞 선 문제를 해결하는데 많은 도움을 받을 수 있었습니다.

 

이제 본격적으로 SpringBoot AOP과 AOP를 사용하는데 활용되는 어노테이션을 정리하는 글을 이어 나가도록 하겠습니다.

 

1. AOP(Aspect-Oriented Programming)란 ?

https://tecoble.techcourse.co.kr/post/2021-06-25-aop-transaction/

AOP란 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사 관점으로 보는 것을 의미합니다. 전통적인 객체 지향 설계 방식을 따라 기능을 설계하면 높은 응집성과 낮은 결합도를 유지할 수 있습니다. 하지만, 각 객체에 걸쳐서 공통적으로 사용되는 부가 기능들이 존재합니다. 로그를 남기거나 트랜잭션 처리를 하는 등 애플리케이션 전반에 걸쳐 흩어져 있는 공통적인 관심사를 하나로 모듈화 하여 관리하는 것을 관점 지향 프로그래밍 AOP라고 부릅니다. 

 

 

2.  AOP 적용 방법

컴파일 실제 대상 코드에 애스펙트를 통한 부가 기능 호출 코드가 포함됩니다. (AspectJ 직접 사용)
클래스 로딩 실제 대상 코드에 애스펙트를 통한 부가 기능 호출 코드가 포함됩니다. (AspectJ 직접 사용)
런타임  실제 대상 코드는 크대로 유지하되, 프록시를 통해 부가 기능이 적용됩니다. (스프링 AOP는 이 방식을 사용)

 

 

3.  AOP 적용 위치

AOP가 적용될 수 있는 적용 가능한 지점을 조인 포인트라 부르는데, 생성자, 필드값 접근, static 메서드 접근, 메서드 실행이 조인포인트가 될 수 있습니다.

AspectJ를 사용해서 컴파일 시점과 클래스 로딩 시점에 적용하는 AOP는 바이트 코드를 실제 조작하기 때문에 해당 기능을 모두 적용할 수 있습니다.  하지만 프록시 방식을 사용하는 스프링 AOP는 메서드 실행 시점에만 AOP를 적용할 수 있습니다.

--> 프록시는 메서드 오버라이딩 개념으로 동작합니다. 따라서, 생성자, static 메서드, 필드 값 접근에는 프록시 개념이 적용될 수 없습니다.

 

 

4. 용어 정리

포인트컷(Pointcut) 조인 포인트 중에서 어드바이스가 적용될 위치, AspectJ 표현식을 사용해서 지정
타겟(Target) 어드바이스를 받는 객체, 포인트컷으로 결정
어드바이스(Advice) 특정 조인 포인트에서 Aspect에 의해 취해지는 조치 (Around, Before 등 어드바이스 존재)
애스팩트(Aspect) 어드바이스와 포인트컷을 모듈화한 기능
어드바이저(Advisor) 하나의 어드바이스에 하나의 포인트컷으로 구성
위빙(Weaving) 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용한 것
위빙으로 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가할 수 있음

 

 

5. 스프링에 AOP 적용하기

스프링에 AOP를 적용하기 위해, 크게 사용될 기능은 @Aspect와 @Around (어드바이스 종류)입니다.

@Aspect는 어노테이션 기반 프록시를 적용할 때 필요합니다.

@Around는 표현식을 value의 값으로 설정하고,  해당 어노테이션을 받는 메서드는 어드바이스가 됩니다.

이를 바탕으로, BlogRepository와 BlogService(Impl)에 적용되는 AOP를 확인하도록 하겠습니다.

 

<Blog를 저장하고 로드하는 기능을 가진 간단한 Repository, Service>

@Slf4j
@Repository
public class BlogRepository {

    public void save(String content) {

        log.info("[BlogRepository.save 실행]");
        if (content == null) {
            log.info("[BlogRepository.save 에러]");
            throw new IllegalArgumentException();
        }

        log.info("[BlogRepository.save 저장]");
    }

    public String load(Long blogId) {

        log.info("[BlogRepository.load 실행]");

        if (blogId == null) {
            log.info("[BlogRepository.load 에러]");
            throw new IllegalArgumentException();
        }

        log.info("[BlogRepository.load 성공]");
        return "안녕하세요";
    }
}
public interface BlogService {
    String load(Long blogId);
    void save(String content);
}
@Slf4j
@Service
@RequiredArgsConstructor
public class BlogServiceImpl implements BlogService {

    private final BlogRepository blogRepository;

    @Override
    public String load(Long blogId) {

        log.info("[BlogService.load 실행]");
        String content = blogRepository.load(blogId);
        log.info("[BlogService.load 완료] content = {}", content);

        return content;
    }


    @Override
    public void save(String content) {

        log.info("[BlogService.save 실행]");
        blogRepository.save(content);
        log.info("[BlogService.save 완료]");

    }
}

 

@Around  어노테이션의 AspectJ 표현식에 사용할 Pointcut을 클래스로 분리하여 Repository와 Service에 중복되는 메서드에 공통적으로 처리하는 Pointcuts 클래스입니다.

@Slf4j
public class Pointcuts {

    @Pointcut("execution(* hello.aop.blog.*.save(..))")
    public void allSave() {}

    @Pointcut("execution(* hello.aop.blog.*.load(..))")
    public void allLoad() {}

    @Pointcut("execution(* hello.aop.blog.BlogService.*(..))")
    public void allService() {}

}

 

Pointcuts을 생성한 후 AOP를 적용할 어드바이스에 내부 static 클래스를 생성하였습니다.

@Around와 @Before @After 등의 다양한 어드바이스 어노테이션을 활용하여 차이점을 확인하였습니다. @Around는 ProceedingJoinPoint를 파라미터로 받는데, 반드시 joinPoint의 proceed()를 실행하여, 해당 핵심 로직이 수행될 수 있도록 설정하여야 합니다. @Around가 아닌 어드바이스 종류의 어노테이션은 부가 기능을 활용하는 데 사용됩니다. 이는 기능을 세분화하여 런타임 시점에 발생할 수 있는 장애를 방지하는 역할을 수행할 수 있습니다.

 

@Slf4j
public class ApplyAspect {

    // --- 중략 ---

    @Aspect
    public static class AllLoad {

        @Before("hello.aop.blog.Pointcuts.allLoad()")
        public void loadBefore(JoinPoint joinPoint) throws Throwable {
            log.info("[Before AllLoad JoinPoint]");
        }

        @AfterReturning(value = "hello.aop.blog.Pointcuts.allLoad()", returning = "result")
        public void loadReturn(JoinPoint joinPoint, Object result) {
            log.info("[AfterReturning AllLoad ProceedingJoinPoint]");
            log.info("[AfterReturning AllLoad joinPoint] Signature = {}", joinPoint.getSignature());
         	// 로그 중략 
            log.info("[AfterReturning AllLoad joinPoint] class = {}", joinPoint.getClass());
            log.info("[AfterReturning AllLoad Result] result = {}", result.toString());
        }

        @AfterThrowing(value = "hello.aop.blog.Pointcuts.allLoad()", throwing = "e")
        public void loadThrowing(JoinPoint joinPoint, Exception e) {
            log.info("[AfterThrowing AllLoad Exception] message = {}", e);
        }

        @After(value = "hello.aop.blog.Pointcuts.allLoad()")
        public void loadAfter(JoinPoint joinPoint) {
            log.info("[After AllLoad joinPoint] Signature = {}", joinPoint.getSignature());
        }
    }

    @Aspect
    public static class AllService {

        @Around("hello.aop.blog.Pointcuts.allService()")
        public Object saveAndLoadAround(ProceedingJoinPoint joinPoint) throws  Throwable {

            try{

                log.info("[Around AllService ProceedingJoinPoint]");
                Object result = joinPoint.proceed();
                log.info("[Around AllService ProceedingJoinPoint] result = {}", result);

                return result;

            } catch (Exception e) {

                log.info("[Around AllService Exception] message = {}", e.getMessage());
                throw e;
            }
        }
    }
}

 

 

6. 테스트 확인하기

@Slf4j
@SpringBootTest
@Import({ApplyAspect.AllLoad.class, ApplyAspect.AllSave.class, ApplyAspect.AllService.class})
class ApplyAspectTest {

    @Autowired
    BlogRepository blogRepository;

    @Autowired
    BlogService blogService;

    @Test
    @DisplayName("BlogRepository save AOP 로그 확인")
    public void saveRepository() throws Exception {
        blogRepository.save("안녕하세요");
    }

    @Test
    @DisplayName("BlogRepository save 실패 AOP 로그 확인")
    public void failSaveRepository() throws Exception {
        Assertions.assertThatThrownBy(() -> blogRepository.save(null))
                        .isInstanceOf(IllegalArgumentException.class);
    }

   /*
   * 중략 
   */
   

    @Test
    @DisplayName("BlogService load AOP 로그 확인")
    public void loadService() throws Exception {
        String load = blogService.load(14L);
        assertThat(load).isEqualTo("안녕하세요");
    }

    @Test
    @DisplayName("BlogService load 실패 AOP 로그 확인")
    public void failLoadService() throws Exception {
        Assertions.assertThatThrownBy(() -> blogService.load(null))
                .isInstanceOf(IllegalArgumentException.class);
    }

}

AOP를 설정한 결과, 핵심 로직에는 작성하지 않았던 부가 기능 로그가 런타임 시점에 프록시가 적용되어 로그가 기록된 것을 확인할 수 있습니다. 또한 @Around ->  @Before -> @AfterReturing 순서로 출력된 것을 확인할 수 있었습니다.

[Around AllService ProceedingJoinPoint]
[BlogService.load 실행]
[Before AllLoad JoinPoint]
// 중략
[AfterReturning AllLoad joinPoint] Signature //중략

 

 

7.  로그 기록 분석하기 

<제 생각이 많이 담긴 부분이라, 정확하지 않을 수 있습니다 !!

제가 잘못 분석한 부분이 있다면 댓글 적어주시면 감사히 배우겠습니다.!!>

 

Signature: 반환 타입, 경로, 클래스 정보, 메서드 정보, 파라미터 타입을 확인할 수 있었습니다.

[AfterReturning AllLoad joinPoint] Signature = String hello.aop.blog.BlogServiceImpl.load(Long)

>> [AfterReturning AllLoad joinPoint] Signature = String hello.aop.blog.BlogServiceImpl.load(Long)

 

Target: 어드바이스를 받는 객체가 BlogServiceImpl의 프록시 형태로 출력되었는데,

이는 런타임 시점에 핵심 로직에 프록시가 적용되어 부가 기능이 추가되는 것과 연관 지어 생각할 수 있었습니다.

>> [AfterReturning AllLoad joinPoint] Target = hello.aop.blog.BlogServiceImpl@7d979d34

 

Args: BlogService.load()의 파라미터로 14를 입력하였는데,

joinPoint에서도 파라미터 정보를 얻을 수 있었습니다.

이를 확장하면 앞 선 security나 transaction 처리 등에도 활용될 수 있습니다.

>> [AfterReturning AllLoad joinPoint] Args = 14

 

class: 한 가지 놀라운 점은 ProceedingJoinPoint와 JoinPoint 모두 동일한

MethodInvocationProceedingJoinPoint 라는 구현체를 출력하였습니다.

>>[Around AllService ProceedingJoinPoint] class = class org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint
>>[AfterReturning AllLoad joinPoint] class = class org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint

 

이는, 비록 같은 구현체를 사용하지만 파라미터로 사용되는 인터페이스가 @Around는 ProceedingJoinPoint 이므로 다형성의 원리로 인해 ProceedingJoinPoint로 캐스팅된 MethodInvocationProceedingJoinPoint만 proceeding() 메서드를 호출할 수 있는 방식이라고 생각하게 되었습니다.

 

스프링 AOP는 정말 멋진 스프링의 기능인 것 같습니다. 어느새 정리하다 보니 두 시간이 훌쩍 지나갔지만 예제를 작성하고 로그를 분석하다보니, 새롭게 더 많은 부분을 알게 되었습니다.!

감사합니다.!

 

참고 자료: 인프런 김영한 님 스프링 핵심 원리 고급편 

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 SpringBoot의 Data JPA에서 제공하는 NativeSQL, JPQL을 활용하여 CASE WHEN을 활용한 집계 함수를 적용하는 과정을 정리하고자 합니다. (수정)

 

Data Jpa를 활용하면 다양한 로직을 구현할 수 있습니다. find(), delete(), save(), update() 등 CRUD 로직을 간단한 jpa 용법을 활용하여 구현할 수 있습니다. 하지만, 복잡한 쿼리를 구현해야 할 때 사용자 정의 메서드를 구현해야 할 필요성이 있습니다.

 

이번에 구현하는 비즈니스 로직은, 다음과 같습니다.

A라는 게임을 참여한 사용자들의 게임 결과를 바탕으로, 사용자 id,  게임에서 이긴 횟수, 게임에서 진 횟수, 게임에서 이긴 비율, 마지막 게임 참여 시간의 데이터를 가져와서 클라이언트에 제공해야 합니다.

 

이때는, SQL의 case when, 집계 함수, round(), group by 등을 활용하여 구현하여야 합니다.

따라서, 비즈니스 로직을 구현하기 위한 클래스와 h2 raw query, Data Jpa의 NativeSQL을 활용하여 해당 비즈니스 로직을 구현하는 과정을 정리하도록 하겠습니다.

 

1. Domain, Repository, Service

 

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Game extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "game_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member host;

    public Game(Member host) {
        this.host = host;
    }

    public static Game of (Member host) {
        return new Game(host);
    }
}
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "gameresult")
public class GameResult extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "game_result_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "game_id")
    private Game game;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "join_member_id")
    private Member member;

    @Enumerated(STRING)
    private Result result;

    @Builder
    public GameResult(Game game, Member member, Result result) {
        this.game = game;
        this.member = member;
        this.result = result;
    }
}
@Repository
public interface GameResultRepository extends JpaRepository<GameResult, Long> {

    
    // to do
    
    }
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class GameResultService {

    private final GameResultRepository repository;

    public Page<AllMemberGameResultAnalysisInterface> fetchAllMembersGameResultAnalysis(Pageable pageable) {
        return repository.fetchAllMembersGameResultAnalysis(pageable);
    }

}

구현해야 하는, Repository는 먼저 설명을 위해 //to do 빈칸으로 작성하였습니다. 

 

가상의 환경을 구현하기 위해, initDb라는 클래스를 작성하여, SpringBoot가 런타임 될 때, 임시 데이터를 입력하였습니다.

gameresult 테이블을 확인하면, 우리가 구현해야 하는 비즈니스 로직에 해당하는 데이터들이 입력된 것을 확인할 수 있습니다. 

비즈니스 로직을 구현하기 위해, Result 컬럼의 데이터를 상황에 맞게 정제하고, 그룹화하는 쿼리를 구현해야 합니다.

 

 

2. Raw Query 작성하기

바로 nativeQuery를 작성하면 좋지만, h2 데이터베이스 콘솔을 활용하여 구현해야 하는 로직을 먼저 작성하여 베이스 라인을 구축하였습니다.

 

- case when 

sql 에서는 조건에 따라 서로 다른 값을 반환할 수 있도록 case 표현식을 제공합니다. 자바에서의 if else if else와 비슷한 용법으로 sql 내에서 활용할 수 있는 구문입니다.

 

- group by

집계 함수를 활용해야 한다면, group by를 활용하여, 특정 컬럼을 기준으로 그룹화하여 작성할 수 있습니다.

 

- round()

라운드 함수는 반올림 기능을 제공하는 함수로, round(칼럼명/표현식, n)으로 작성하며, 해당 표현식의 결과를 n(소수점)의 자리까지 반올림하는 기능을 제공합니다.

소수점을 나타낼 때 데이터가 정수형이라면, 표현식 * 1.0을 하여 해당 타입을 실수형으로 바꿔주어야 합니다.

 

이를 바탕으로, Sql 쿼리를 작성하면 다음과 같습니다. 목표가 이긴 횟수, 진 횟수, 이긴 비율을 구해야 하므로 이겼을 경우의 케이스를 count 한 값, 진 케이스를 count 한 값, 이긴 횟수를 count 한 결과에서 총 result 개수를 나누고 * 100 한 후, 첫째 자리까지 반올림하여 표현하였습니다.

select  
join_member_id,
count(case when result = 'WIN' then 1 end) win, 
count(case when result = 'LOSE' then 1 end) lose,
round(count(case when result = 'WIN' then 1 end) / (count(result) * 1.0) * 100,  1) as rate,
max(modified_at) as lastjointime
from gameresult
group by join_member_id;

 

그 결과 비즈니스 로직에서 구현해야하는 쿼리를 작성할 수 있습니다.

 

3. Data Jpa에 적용하기

 

-  Wrapper.class를 적용할 interface를 구현하기

처음에는 native query로 구현할 때, Dto에 매핑되도록 구현하였습니다. 하지만, 매핑 에러가 반복되었고, 구글링 결과 인터페이스로 선언한 Dto에 매핑하여야 한다는 설명이 있었습니다. 따라서, 인터페이스를 Dto로 활용하기 위해 작성하였습니다.

public interface AllMemberGameResultAnalysisInterface {

    Long getId();
    Long getWin();
    Long getLose();
    Double getRate();
    LocalDateTime getLastjointime();

}
public interface AllMemberGameResultAnalysisJpqlInterface {

    Long getId();
    Long getWin();
    Long getLose();
    Double getRate();
    LocalDateTime getLastJoinGameTime();

}

이를 바탕으로 Repository에 native 쿼리를 구현한 결과는 다음과 같습니다.

count() -> sum()으로 수정하여 작성하였고, 매핑하는 과정에서 일반 jpql과 달리 실제 데이터베이스에 적용된 컬럼명을 적어줘야 하는 한계가 존재했습니다. 만약 paging 처리를 해야 한다면 @Query() 안에 countQuery = "" 로직을 구현해야 합니다.

@Repository
public interface GameResultRepository extends JpaRepository<GameResult, Long> {

	@Query(value =
            "select g.member.id as id, " +
                    "sum(case when (g.result = 'WIN') then 1 else 0 end) as win, " +
                    "sum(case when g.result = 'LOSE' then 1 else 0 end) as lose, " +
                    "round(sum(case when (g.result = 'WIN') then 1 else 0 end) / (count(g.result) * 1.0) * 100, 1) as rate, " +
                    "max(g.modifiedAt) as lastJoinGameTime " +
                    "from GameResult g " +
                    "join Member m on g.member.id = m.id " +
                    "group by g.member.id")
    Page<AllMemberGameResultAnalysisJpqlInterface> fetchAllMembersGameResultAnalysisJpql(Pageable pageable);



    @Query(value =
            "select g.join_member_id as id, " +
                    "sum(case when (g.result = 'WIN') then 1 else 0 end) as win, " +
                    "sum(case when g.result = 'LOSE' then 1 else 0 end) as lose, " +
                    "round(sum(case when (g.result = 'WIN') then 1 else 0 end) / (count(g.result) * 1.0) * 100, 1) as rate, " +
                    "max(g.modified_at) as lastjointime " +
                    "from GameResult g " +
                    "join Member m on g.join_member_id = m.member_id " +
                    "group by g.join_member_id " +
                    "order by g.join_member_id ASC",
            countQuery = "select count(*) from GameResult g",
            nativeQuery = true)
    Page<AllMemberGameResultAnalysisInterface> fetchAllMembersGameResultAnalysis(Pageable pageable);

}

 

4. 테스트 하기

(@BeforEach에 사용한 구문은 제거하였습니다)

 

@SpringBootTest
@Transactional
class GameResultServiceTest {

    @Autowired
    EntityManager em;

    @Autowired
    GameResultService gameResultService;
    
    @Test
    @DisplayName("각 회원의 이긴 횟수, 진 횟수, 승리 확률을 페이징하여 가져 온다.(JPQL)")
    public void fetchAllMemberGameResultAnalysisJpql() throws Exception {
        //given
        Pageable page = PageRequest.of(0, 10);

        //when
        Page<AllMemberGameResultAnalysisJpqlInterface> result = gameResultService.fetchAllMembersGameResultAnalysisJpql(page);

        //then
        assertThat(result.getContent().size()).isEqualTo(10);
        assertThat(result.getContent().get(0).getWin()).isEqualTo(1L);
        assertThat(result.getContent().get(1).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(3).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(4).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getRate()).isEqualTo(20.0);
        assertThat(result.getContent().get(2).getLastJoinGameTime()).isBefore(LocalDateTime.now());

    }

    

    @Test
    @DisplayName("각 회원의 이긴 횟수, 진 횟수, 승리 확률을 페이징하여 가져 온다.")
    public void fetchAllMemberGameResultAnalysis() throws Exception {
        //given
        Pageable page = PageRequest.of(0, 10);
        
        //when
        Page<AllMemberGameResultAnalysisInterface> result = gameResultService.fetchAllMembersGameResultAnalysis(page);
        
        //then
        assertThat(result.getContent().size()).isEqualTo(10);
        assertThat(result.getContent().get(0).getWin()).isEqualTo(1L);
        assertThat(result.getContent().get(1).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(3).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(4).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getRate()).isEqualTo(20.0);
        assertThat(result.getContent().get(2).getLastjointime()).isBefore(LocalDateTime.now());

    }

}

페이징 작업을 수행하기 위해 Pageable 구현체인 PageRequest를 활용하여 테스트한 결과, 이상 없이 원하는 비즈니스 로직을 구현할 수 있었습니다.

 

5. 한계 및 DataJpa 장점

실제 Native Query를 작성하는 과정에서 많은 에러가 발생하였습니다.

기본적인 컬럼명이 맞지 않아 에러가 발생하였고, 자바에서 추구하는 camel case 용법이 아니라, 데이터베이스에서 사용하는 snake case 방식으로 직접 쿼리를 작성해야 하다 보니 Dto 역할을 하는 interface의 메서드와 매핑이 되지 않는 문제가 발생하였습니다. 또한, 파일 시점에 에러를 확인할 수 없어서 테스트를 돌리는 과정을 반복하는 문제가 발생하였습니다.

 

DataJpa에서 제공하는 @Query를 활용하여, JPQL을 작성하니, 앞에서 발생한 에러를 해결할 수 있었고, Wrapper로 선언한 인터페이스가 nativeQuery에서는 camel case를 적용하기 어려웠는데, 인터페이스명까지 camelCase 형태로 mapping 될 수 있었습니다. 마지막에 쿼리가 나가는 것을 확인하면 다음과 같습니다.

 

select
        gameresult0_.join_member_id as col_0_0_,
        sum(case 
            when gameresult0_.result='WIN' then 1 
            else 0 
        end) as col_1_0_,
        sum(case 
            when gameresult0_.result='LOSE' then 1 
            else 0 
        end) as col_2_0_,
        round(sum(case 
            when gameresult0_.result='WIN' then 1 
            else 0 
        end)/(count(gameresult0_.result)*1.0)*100,
        1) as col_3_0_,
        max(gameresult0_.modified_at) as col_4_0_ 
    from
        gameresult gameresult0_ 
    inner join
        member member1_ 
            on (
                gameresult0_.join_member_id=member1_.member_id
            ) 
    group by
        gameresult0_.join_member_id limit ?

 

처음 글을 작성하였을 때에는, JPQL 작성 시 에러가 났었는데, JPQL도 인터페이스를 Dto로 받으니 에러가 해결되었습니다.!

역시 JPA는 정말 대단하고 아름다운 것 같습니다.ㅎㅎ!

읽어주셔서 감사드립니다.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

오늘은, 영한님 스프링 핵심 원리 - 고급편의 강의를 듣고, 스프링 제공 빈 후처리기를 추가로 실습하고 정리한 내용을 

작성하고자 합니다.

 

빈 후처리기 적용에 앞서, 빈 후처리기와 같은 기능을 사용하는 이유를 정리해보고자 합니다.

 

애플리케이션 실행 시 의도대로 사용되어야 하는 기능은 비즈니스 로직입니다. 하지만, 기획 의도나 개발 운영에 있어 부가적인 기능을 추가로 실행시켜야 하는 상황이 생길 수 있습니다. 이를 부가 기능이라고 하는데, 만약 프록시 기술 적용 없이, 부가 기능을 적용하려고 한다면 기존 애플리케이션에 존재하는 비즈니스 로직에 추가 코드를 삽입해야 하는 상황이 생길 수 있습니다.

 

만약, 단순하게 로그를 찍는 상황이 아니라, 해당 클라이언트의 요청에 따라, controller -> service -> repository 등의 순서로 이동하는 단계별 로그 추적기를 적용해야하는 상황이라면, 파라미터로 로그의 단계를 남겨야 하므로, 코드를 전부 수정해야 하는 상황이 생길 수 있습니다. 이럴 경우, 프록시를 삽입하며 target 인스턴스의 기능은 정상적으로 실행시키고 중간에서 부가적으로 실행할 기능을 적용하는 것이 프록시 기능의 의도라고 할 수 있습니다.

 

@Slf4j
public class OrderService {

    public void orderItem(Item item) {
        item.minusQuantity(); // 주요 기능 (주문하면 수량을 제거하는 로직)
        log.info("주문을 수행 합니다."); //부가 기능 (로그를 남기는 기능)
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    private class Item {
        private String name;
        private int quantity;

        public void minusQuantity() {

            if (checkQuantity()) {
                if (quantity > 0) {
                    quantity--;
                }
            }
        }

        private boolean checkQuantity () {
            if (quantity < 0) {
                throw new IllegalStateException("error");
            }
            return true;
        }
    }

}

 

1. 빈 후처리기

스프링은 스프링 빈으로 등록된 객체를 생성하고 컨테이너에 등록합니다. 빈 후처리기는 객체를 빈으로 등록하기 전에 조작하는 객체입니다. 빈 후처리기는 다른 빈보다 먼저 컨테이너에 등록되며, 다른 빈들이 등록될 때 후 처리기를 수행합니다.

 

  1. 생성 : 스프링 빈 대상이 되는 객체를 생성
  2. 전달 : 생성된 객체를 빈 저장소에 등록하기 직전, 빈 후처리기로 전달
  3. 후처리 작업 : 전달된 스프링 빈 객체를 조작한다.
  4. 등록 : 후처리기는 객체를 조작 후 조작된 객체를 반환하는데, 이 반환된 객체가 빈으로 등록, 스프링 컨테이너로 등록된다.

 

스프링에서 제공하는 빈 후처리기는 aop 라이브러리를 추가하여 사용할 수 있습니다.

implementation 'org.springframework.boot:spring-boot-starter-aop'

 

2. AnnotationAwareAspectAutoProxyCreator

스프링이 제공하는 빈 후처리기로 스프링 빈으로 등록된 Advisor를 찾아서 자동으로 프록시 적용이 필요한 곳에 프록시를 적용하여 스프링 빈으로 등록합니다. 

 

Pointcut / Advice / Advisor??

Pointcut 필터링할 위치를 지정하는 로직으로, 어디에 부가 기능을 적용할지 초점을 둡니다.
Advice 프록시가 적용할 부가기능 로직이라고 할 수 있습니다.
Advisor 하나의 pointcut과 하나의 advice를 가지며,
pointcut과 advice를 가지고 빈에 등록되어 빈 후처리기 적용이 됩니다.

 

 

3. 실습 코드 작성하기

(코드는 영한님 강의를 바탕으로 구성되었지만, 저작권이 있으므로 따로 수정하여 작성하였습니다. 자세한 내용은 영한님 스프링 핵심원리 - 고급편을 수강하시면 확인하실 수 있습니다.!)

 

빈 후처리기를 사용하여 프록시를 적용하면, 비즈니스 로직을 구현한 객체와 부가 기능을 구현한 객체를 분리하여 코드를 작성할 수 있습니다. 하단에는 repository, service, controller가 작성되어 있는데 각 메소드에는 핵심 로직만 구성되어 있습니다.

@Repository
public class AutoProxyRepository {

    public void join(String name) {

        try{
            if (name.equals("error")) {
                throw new IllegalArgumentException("error");
            }
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
@Service
@RequiredArgsConstructor
public class AutoProxyService {

    private final AutoProxyRepository autoProxyRepository;

    public void joinTeam(String name) {
        autoProxyRepository.join(name);
    }
}
@RestController
@RequestMapping("/api/v1/join")
@RequiredArgsConstructor
public class AutoProxyController {

    private final AutoProxyService autoProxyService;

    @GetMapping
    public ResponseEntity joinTeam(@RequestParam("name") String name) {
        autoProxyService.joinTeam(name);
        return new ResponseEntity<>(HttpStatus.OK);
    }

}
@Slf4j
@Component
public class LogTraceV1 {

    public void doLog(String message) {
        log.info("method = {}", message);
    }
}

 

비즈니스 로직이 아닌 부가기능을 수행하는 부분은 LogTraceV1입니다. 만약 클라이언트 요청에 따라, 수행되는 일련의 과정에 모두 로그를 남기고 싶을 때, 해당 객체를 의존성 주입받아서 로직을 변경하기보다 빈 후처리기를 활용하여 프록시 패턴으로 적용할 수 있습니다.

@RequiredArgsConstructor
public class LogTraceV1Advice implements MethodInterceptor {

    private final LogTraceV1 logTraceV1;

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {

        try {

            Method method = invocation.getMethod();
            String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
            logTraceV1.doLog(message);

            Object result = invocation.proceed();

            return result;

        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
}

LogTraceV1을 Advice로 구현하는 역할을 수행하는 클래스가 LogTraceAdvice입니다. 주의할 사항은 MethodInterceptor는  org.aopalliance.interceptor.MethodInterceptor를 import 해야 합니다.

 

MethodInterceptor는 부가 기능을 제공하는 역할을 수행할 수 있도록 하는 콜백 오브젝트입니다. proceed() 메소드를 실행하면 타겟 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있으며, 해당 클래스를 구현하면 일종의 공유 가능한 템플릿처럼 사용할 수 있습니다. 

 

@Configuration
public class AutoProxyConfigV1 {
    
    @Bean
    public Advisor advisorV2(LogTraceV1 logTraceV1) {

        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(
                "execution(* hello.advanced.autoproxy..*(..)) " +
                        "&& !execution(* hello.advanced.autoproxy..join(..))"
        ); //autoproxy 하위 모든 패키지, 파라미터 모두 적용
        LogTraceV1Advice advice = new LogTraceV1Advice(logTraceV1);
        return new DefaultPointcutAdvisor(pointcut, advice);
    }

}

앞서 생성한 Advice와 Pointcut을 파라미터로 받아, Advisor를 생성하는 역할을 수행하는 Configuration이 AutoProxyConfigV1입니다.

 

Pointcut을 생성하는 방법은 여러 가지가 있는데, NameMatchMethodPointcut과 AspectJExpressionPointcut 등이 있습니다. NameMatchMethodPointcut은 클래스와 메소드의 이름 패턴을 비교하여, AspectJExpressionPointcut은 AspectJ의 포인트컷 표현식을 활용하여 Pointcut에 적용되는 메소드를 판별할 수 있습니다.

 

이상으로 빈 후처리기에 관한 글을 마치도록 하겠습니다.

부족하지만 읽어주셔서 감사합니다.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

오늘은 의존성 주입을 활용하여 테스트하기 어려운 코드를 테스트해보는 과정을 포스팅하고자 합니다.

 

SpringBoot를 활용하여 단위테스트를 작성할 때, 여러 의존성 주입으로 인해 원하는 부분의 단위 테스트를 작성하지 못할 수 있습니다.

 

예를 들어, A라는 메서드에 B, C의 메소드의 값을 리턴 받거나, 실행되어야 하는 메서드인 경우,  A라는 메서드를 실행하기 위해 B, C 모두 실행되어야 합니다. 아래와 같은 경우, methodA를 단위 테스트하는 과정에 methodB, methodC를 참조하고 있으므로 methodB 및 methodC를 검증한 상태라면 중복되는 테스트라고 볼 수 있습니다.

해당 예시는 처리하기 용이한 과제이기 때문에 테스트 코드 작성하는 것이 난해하지 않지만, 복잡한 과정일 경우, 여간 쉬운 일이 아닙니다.

 

private class A {

    String methodA(String name) {
        B b = new B();
        String bName = b.methodB(name);
        C c = new C();
        String cName = c.methodC(bName);

        if (cName == name) {
            return name;
        }

        return null;
    }

}

private class B {

    String methodB(String name) {
        return name;
    }
}

private class C {
    String methodC(String name) {
        return name;
    }
}

 

1. 문제 상황

이번에 테스트하고자 한 예시는, OAuth2 로그인에서 ProviderUser OAuth2UserService<oAuth2UserRequest, OAuth2User> f를 구현한 CustomOAuth2UserService의 loadUser입니다.

 

먼저, 코드를 제시하겠습니다.

 

@Service
@Slf4j
@Getter
public abstract class AbstractOAuth2UserService {

    private final MemberService memberService;

    public AbstractOAuth2UserService(MemberService memberService) {
        this.memberService = memberService;
    }

    public void register(ProviderUser providerUser, OAuth2UserRequest userRequest){

        Member member = memberService.findByEmailNoOptional(providerUser.getEmail());

        if(member == null){
            ClientRegistration clientRegistration = userRequest.getClientRegistration();
            memberService.register(clientRegistration.getRegistrationId(), providerUser);
        }
    }

    public ProviderUser providerUser(ClientRegistration clientRegistration, OAuth2User oAuth2User) {

        String registrationId = clientRegistration.getRegistrationId();


        switch (registrationId) {

            case "google":
                return new GoogleUser(OAuth2Utils.getMainAttributes(oAuth2User), oAuth2User, clientRegistration);

            case "naver":
                return new NaverUser(OAuth2Utils.getSubAttributes(oAuth2User, "response"), oAuth2User, clientRegistration);

            case "kakao":
                if (oAuth2User instanceof OidcUser) {
                    return new KakaoOidcUser(OAuth2Utils.getMainAttributes(oAuth2User), oAuth2User, clientRegistration);

                } else {
                    return new KakaoUser(OAuth2Utils.getOtherAttributes(oAuth2User, "kakao_account", "profile"), oAuth2User, clientRegistration);
                }

            default:
                return null;
        }
    }
}

  

  • AbstractOAuth2UserService
    • client의 registrationId(socialId)에 따라, 소셜 로그인 방식을 다르게 하는 service 로직입니다.
    • 유저 oauth2 로그인 요청이 발생하면, providerUser 메서드로 ProviderUser 객체 생성 역할을 수행합니다.

 

@Slf4j
@Service
public class CustomOAuth2UserService extends AbstractOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    public CustomOAuth2UserService(MemberService memberService) {
        super(memberService);
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);

        ProviderUser providerUser = super.providerUser(clientRegistration, oAuth2User);

        super.register(providerUser, userRequest);

        return new PrincipalUser(providerUser);
    }
}
  • CustomOAuth2UserService
    • client의 oauth2 로그인 요청이 처리되는 구현체 클래스로 추상 클래스 AbstractOAuth2UserService를 상속받고, OAuth2UserService<OAuth2UserRequest, OAuth2User> 클래스를 구현합니다.
    • 구현 메서드는 loadUser이고, 해당 리턴 타입은 OAuth2User입니다.
    • PrincipalUser는 OAuth2User 및 폼 로그인 OidcUser를 구현한 공통 로그인 클래스이고, 이번 포스팅에서 검증하고자 하는 대상입니다.

 

 

2. 의존성 주입 파악하기

loadUser 메서드를 테스트하기 위한 방법은 여러 가지가 있습니다. 처음부터 mock 객체를 활용하는 방법도 있지만, 이번에 포스팅하는 주제인 의존성 주입을 활용하여 테스트 코드를 작성한 방법을 공유하겠습니다.

 

의존성 주입이란?

외부에서 두 객체 간의 관계를 결정해주는 디자인 패턴으로, 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않도록 하고 런타임 시에 관계를 동적으로 주입하여 유연성을 확보하고 결합도를 낮출 수 있게 합니다.

 

여기에 왜 필요할까?

해당 소스는 크게 다른 4개의 메서드 혹은 로직이 순서대로 연결되어 있는 메서드입니다. 따라서, loadUser를 테스트하기 위해서는 복잡한 4개의 절차를 거쳐야 합니다.

 

하지만, 단위테스트를 진행할 때, providerUser, registerUser는 이 메서드 이전에도 테스트된 코드일 수 있고, 해당 메서드가 명확하게 OAuth2User를 리턴하는지를 파악하기 위한 코드만 필요하다면, mock객체를 활용하여 테스트 코드를 작성하는 것이 어렵고 복잡한 일이 될 수 있습니다. 이때 활용할 수 있는 방법이 의존성 주입을 활용한 방법입니다.

 

 

CustomOAuth2UserService를 대신할 CustomOAuth2UserServiceMock 클래스와
ProviderUser를 제공해 줄 ProviderUserService 인터페이스를 생성합니다.
DefaultProviderUserService는 ProviderUserService 구현체로
loadUser에 복잡한 로직인 ProviderUser를 생성하는 역할을 수행합니다.
따라서, CustomOAuth2UserServiceMock은 ProviderUser를 받아서
PrincipalUser 객체를 생성하는 로직을 수행하게 됩니다.

 

 

3. 코드로 확인하기

 

  • ProviderUserService
    • ProviderUser 객체를 제공하는 인터페이스 생성하기
interface ProviderUserService {
    ProviderUser getProviderUser(OAuth2UserRequest userRequest);
}

 

  • DefaultProviderUserService 생성
    • ProviderUserService를 구현
    • AbstractOAuth2UserService에 있는 register() 메서드까지 활용하기 위해 추상 클래스 상속
class DefaultProviderUserService extends AbstractOAuth2UserService implements ProviderUserService {

    public DefaultProviderUserService(MemberService memberService) {
        super(memberService);
    }

    @Override
    public ProviderUser getProviderUser(OAuth2UserRequest userRequest) {
        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);

        return providerUser(clientRegistration, oAuth2User);
    }

}

 

  • CustomOAuth2UserServiceMock
    • CustomOAuth2UserService를 대신할 TestClass 생성
public class CustomOAuth2UserServiceMock extends AbstractOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final ProviderUserService providerUserService;

    public CustomOAuth2UserServiceMock(MemberService memberService,
                                       ProviderUserService providerUserService) {
        super(memberService);
        this.providerUserService = providerUserService;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        ProviderUser providerUser = providerUserService.getProviderUser(userRequest);

        return new PrincipalUser(providerUser);
    }
}

 

  • Mock을 활용한 테스트 케이스 작성
    • CustomOAuth2UserServiceMock을 활용
    • 중간 과정을 생략하고 PrincipalUser 객체가 생성되는 과정만 테스트를 실행하여 확인할 수 있습니다.
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CustomOAuth2UserServiceTest {

    @Mock
    ClientRegistration clientRegistration;

    @Mock
    OAuth2User oAuth2User;

    @Mock
    MemberService memberService;

    @Mock
    ProviderUserService providerUserService;

    @Test
    @DisplayName("oauth2 provider를 체크하기")
    public void providerUser() throws Exception {

        //given
        when(clientRegistration.getRegistrationId()).thenReturn("google");

        //when
        CustomOAuth2UserService oAuth2UserService = new CustomOAuth2UserService(memberService);
        ProviderUser providerUser = oAuth2UserService.providerUser(clientRegistration, oAuth2User);

        //then
        assertThat(providerUser).isInstanceOf(GoogleUser.class);
    }

    @Test
    @DisplayName("oauth2 회원가입 등록하기")
    public void register() throws Exception {
        //given
        OAuth2UserRequest userRequest = mock(OAuth2UserRequest.class);
        when(clientRegistration.getRegistrationId()).thenReturn("google");

        CustomOAuth2UserService oAuth2UserService = mock(CustomOAuth2UserService.class);
        ProviderUser providerUser = oAuth2UserService.providerUser(clientRegistration, oAuth2User);

        //when
        when(providerUserService.getProviderUser(userRequest)).thenReturn(providerUser);
        CustomOAuth2UserServiceMock customOAuth2UserServiceMock = new CustomOAuth2UserServiceMock(memberService, providerUserService);
        OAuth2User oAuth2User = customOAuth2UserServiceMock.loadUser(userRequest);

        //then
        assertThat(oAuth2User).isNotNull();
    }


}

 

 

이상으로 의존성 주입을 활용한 테스트하기 어려운 코드를 테스트하는 과정을 살펴보았습니다. 더 좋은 방법이 있고, 

간단하게 해결할 수 있는 방법이 있다면 조언 부탁드리겠습니다,!

열심히 배우겠습니다.!

 

읽어주셔서 감사드립니다.! 새해 복 많이 받으세요~!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 SpringBoot의 Interceptor에 대한 내용과 활용법에 대해 정리하고자 합니다.

먼저, 이번 포스팅에 많은 도움을 준 블로그 주소는 아래 링크입니다. Spring뿐만 아니라, 다양한 정보를 자세하게 설명해 주셨는데, 정말 도움 많이 되는 블로그여서 추천드립니다.!

(링크: https://mangkyu.tistory.com/173)

 

 

1. 인터셉터(Interceptor)란?

https://velog.io/@_koiil/Filter-Interceptor-AOP

 

a. 인터셉터 의미

 

인터셉터는 스프링에서 제공하는 기술로써 디스패처 서블릿(Dispacher Servlet)이 컨트롤러를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할 수 있는 기능을 제공합니다. 스프링 레벨에서 지원하는 서블릿 필터이기 때문에, 스프링의 모든 빈 객체에 접근할 수 있으며, 활용도는 Filter와 달리, 부적인 인증/인가 단계나 로깅 작업 등 처리에 사용이 됩니다.

 

b. 인터셉터 메서드

preHandler() 컨트롤러가 호출되기 전에 사용됩니다. 따라서, 컨트롤러 이전에 처리해야하는 전처리 작업이나
요청 정보를 가공하는데 사용됩니다.
리턴 타입은 boolean으로, 반환값이 true이면 다음 단계로 진행되고, false면 요청이 중단되어
다음 인터셉터나 컨트롤러 절차가 진행되지 않습니다.

postHanler() 컨트롤러 메소드 실행 직후, view 페이지 렌더링 되기 전 사용합니다.
afterCompletion() 모든 뷰에서 최종 결과를 생성하는 일을 포함해 모든 작업이 완료되면 실행됩니다. 
postHandler와 달리 작업 중간에 예외가 발생하더라도 반드시 호출됩니다.

 

c. 인터셉터는 왜 사용할까?

 

인터셉터는 필터와 달리, 세부적인 요청을 처리하거나 가공하는데 용이합니다. Filter가 보다 앞선에서 공통적인 인증/인가 처리 등을 담당하는 역할을 수행한다면, 디스패처 서블릿을 통과한 인터셉터는 컨트롤러 이전에 필요한 요청 파라미터에 해당 객체가 가지는 내부적인 값에 값을 가공하거나, 특정 사용자의 세부적인 권한을 판단하여 요청 uri로의 접근 액세스 승인 혹은 거부하는 역할을 수행합니다.

 

 

2. 인터셉터 활용하기

/**
 * Group의 관리자를 위한 Interceptor
 *
 */
@Slf4j
@RequiredArgsConstructor
public class GroupAdminInterceptor implements HandlerInterceptor {

    private final static String MEMBER = "member";
    private final static String CREATOR = "creator";
    
    @Resource
    private GroupService groupService;

    @Resource
    private MemberService memberService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 1번
        Map<?, ?> pathVariables = (Map<?, ?>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);


        String email = request.getUserPrincipal().getName();
        Long uriId = Long.valueOf(String.valueOf(pathVariables.get("groupId")));

        try {
            Member member = memberService.findByEmail(email);
            Member creator = groupService.findById(uriId).getCreator();

            if (!Objects.equals(member.getId(), creator.getId())) {

                // 2번
                response.sendRedirect("/api/v1/groups/admin/errors?status=403");
                return false;
            }

        } catch (IllegalArgumentException e) {
            e.getMessage();
            response.sendRedirect("/api/v1/groups/admin/errors?status=404");
            return false;
        }
        return true;
    }

    // 3번
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }
}

주석 1번:

Map<?, ?> pathVariables = (Map<?, ?>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
인터셉터에서, 요청을 처리할 때, URL 정보를 가져오면, url에 있는 정보가 PathVariable인지 알 수가 없습니다. 이러한 문제점을 해결하기 위해, request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); 를 사용하면, url에서 pathvariable 변수명으로 key, value 형태로 처리할 수 있습니다. 따라서, Pathvariable에 해당하는 groupId(key)를 get(value)으로 가져올 수 있습니다.

 

 

주석 2번:

인터셉터는 boolean 값으로 다음 절차를 진행하는지 중단하는지 처리할 수 있습니다. 즉, 컨트롤러가 로직을 수행하기 전에 해당 인터셉터가 실행되는 것입니다. 이 코드에 한정해서 설명하면, group을 만든 creator와 지금 해당 uri로 접근을 요청한 member의 Id 정보가 같지 않다면, 다른 uri로 리다이렉트 요청을 수행하는 것입니다. 따라서, 위에서 설명드린 세부적인 권한을 판단하거나 인증/인가를 처리하는 데 사용될 수 있는 이유가 이러한 예시로 확인할 수 있습니다.

 

 

3. Config 및 예외 컨트롤러 등록하기

@Configuration
public class GroupAdminConfig implements WebMvcConfigurer {


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(groupAdminInterceptor())
                .order(1)
                .addPathPatterns("/api/v1/groups/admin/**")
                .excludePathPatterns("/static/**", "/css/**", "/*.icon", "/error", "/js/**",
                        "/api/v1/groups/admin/errors",
                        "/api/v1/groups/admin/list",
                        "/api/v1/groups/admin/new");
    }

    @Bean
    public GroupAdminInterceptor groupAdminInterceptor() {
        return new GroupAdminInterceptor();
    }
}

 

@GetMapping("/admin/errors")
public String getError(@RequestParam("status") String status) {

    log.info("status = {}", status);

    return "errors/404";

}

 

해당 인터셉터를 InterceptorRegistry에 등록하기 위해서, WebMvcConfigurer를 구현한 구현체를 생성합니다.

WebMvcConfigurer를 구현하면, void addInterceptors(InterceptorRegistry registry)를 구현하게 됩니다.  order()는 해당 인터셉터의 순서를 정하는 것이고, addPathPatterns는 해당 인터셉터의 적용을 처리할 Patterns를 정의합니다. 위의 예시의 경우, /api/1/groups/admin/ 을 포함하는 요청은 모두 인터셉터를 거치는 과정을 수행한다고  명시하는 것입니다. excludePathPatterns는 보통 static에 있는 정적 데이터를 excludePathPatterns 처리합니다.

 

 

4. 적용 확인하기

 

ex) A 계정의 사용자가 7번 그룹의 관리자일 때,

 

 

ex) B 계정의 사용자가 7번 그룹의 관리자가 아닐 때,

 

 

 

이상으로 SpringBoot 인터셉터(Interceptor)에 대한 글을 마치도록 하겠습니다. 감사합니다.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다. 

오늘은 SpringBoot의 REST API에서 자주 사용하거나 잘못 알고 있었던 어노테이션들을 정리하는 시간을 가지려고 합니다.

 

1. @RestController

이 어노테이션은 @Controller나 @Service와 같이 스테레오타입의 어노테이션으로 이 어노테이션이 지정된 클래스를 스프링 컴포넌트 검색으로 찾을 수 있습니다. 스테레오타입의 어노테이션이란 스프링 컨테이너가 스프링 관리 컴포넌트로 식별하게 해주는 어노테이션입니다. scan-auto-detection과 dependency injection을 사용하기 위해서 사용됩니다. 

이 어노테이션을 사용하면 컨트롤러의 모든 HTTP 요청 처리 메서드에서 HTTP 응답 body 직접 쓰는 값을 반환한다는 것을 스프링에게 알려주는 역할을 수행합니다. 따라서, 반환 값이 HTML 형태의 뷰가 아닌 HTTP 응답으로 값을 전달할 수 있습니다. 만약, @Controller를 사용한다면 @ResponseBody 어노테이션을 함께 사용해야 합니다.

https://incheol-jung.gitbook.io/docs/q-and-a/spring/stereo-type

 

2. @RequestMapping

RequestMapping은 http 요청이 온 uri와 특정 메서드를 매핑시켜주는 역할을 수행합니다. @RequestMapping은 메서드단에서 사용하면 특정 메서드의 uri에 매핑되고 아래 코드와 같이 클래스 단에서 사용하면, 공통적인 uri를 묶어서 사용할 때 활용할 수 있습니다. @RequestMapping은 value, path, produces 등을 사용하는데, value는 path의 별칭이라고 합니다. (아직까지 value와 path로 인한 에러는 발생한 경험은 없지만 해당 어노테이션의 파라미터에 대한 차이점은 기회가 되면 정리하도록 하겠습니다.)

produces는 Accpet 헤더에 "application/json"이 포함된 요청만 처리하겠다는 것을 의미합니다.

 

@Slf4j
@RestController
@RequestMapping(path = "/api/v1/schools", produces = "application/json")
@RequiredArgsConstructor
public class SchoolController {

    private final SchoolService schoolService;

    @GetMapping("/search")
    public ResponseEntity getSchoolsSearchRes(@RequestBody SchoolSearchReqDto reqDto) {
        Page<SchoolSearchDto> schoolSearchDto = schoolService.getSchoolSearchDto(reqDto);
        return new ResponseEntity(new RestPage<>(schoolSearchDto), HttpStatus.OK);
    }

3. @CrossOrigin

아래 예시에는 사용되지 않았지만, 백엔드 팀과 프론트 팀이 협업하여 프로젝트를 진행한다고 하면, 프론트팀은 서버 api와 별도의 도메인(프로토콜과 호스트 및 포트로 구성)을 사용할 가능성이 있습니다. 이때, 동일 출처 정책(동일 출처 정책은 동일한 출처의 리소스에만 접근하도록 제한)으로 인해 에러가 발생할 수 있습니다. 이런 제약은 서버 응답에 CORS(Cross-Origin Resource Sharing) 헤더를 포함시켜 극복할 수 있습니다. CrossOrigin(origin="*") 어노테이션을 사용하면, 다른 도메인도 해당 REST API를 사용할 수 있게 허용합니다.

추가로, 해당 CORS를 허용하는 방법은 어노테이션 이외에 Config를 작성하여 Filter를 빈으로 등록하는 방법이 있습니다. 이는 4번과 연관 지어서 설명하겠습니다.

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.addAllowedOriginPattern("*");
        corsConfiguration.addAllowedOriginPattern("http://localhost:3000");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");

        source.registerCorsConfiguration("/api/**", corsConfiguration);
        return new CorsFilter(source);
    }

}

 

4. @Configuration 및 @Bean

Configuration 어노테이션은 각 빈을 스프링 애플리케이션 컨텍스트에 제공하는 구성 클래스라는 것을 스프링에게 알려줍니다. 구성 클래스의 메서드는 @Bean 어노테이션으로 지정되어 있습니다. 이것은 각 메서드에 반환되는 객체가 애플리케이션 컨텍스트 빈으로 추가되어야 한다는 것을 의미합니다.

 

CorsConfig를 @Configuration으로, corsFilter를 @Bean으로 설정하면, 스프링 컨테이너가 corsFilter를 런타임 시점에 생성하여 스프링 빈으로 등록합니다. 

따라서, 해당필터를 SecurityConfig에 filter로 등록한다면, CrossOrigin 어노테이션을 사용하지 않고 CORS error를 방지할 수 있습니다.

// SecurityConfig FilterChain 
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);

 

5. @RequestBody

@RequestBody 어노테이션의 역할은 클라이언트가 보내는 HTTP 요청 본문(JSON, XML 등)을 JAVA 객체로 변환하는 역할을 수행합니다. HTTP 요청 본문 데이터는 Spring이 제공하는 HttpMessageConverter를 통해 타입에 맞는 객체로 변환됩니다. 이때, 기본생성자로 JSON 값을 JAVA 객체로 바인딩할 수 있습니다.

 

a. 어떻게 JSON을 JAVA 객체로 재구성할 수 있을까?

MappingJackson2HttpMessageConverter는 ObjectMapper를 통해 JSON 값을 java 객체로 역직렬화하는 것을 알 수 있습니다. 역직렬화란 생성자를 거치지 않고 리플렉션을 통해 객체를 재구성하는 메커니즘입니다. 직렬화가 가능한 클래스들은 기본 생성자가 필수입니다. 따라서, @RequestBody에 사용하려는 Dto 클래스는 기본 생성자가 없으면 바인딩에 실패합니다.

 

b. 구조 확인해 보기

디버깅 과정에서 해당 MappingJackson2HttpMessageConverter가 어떠한 방식으로 작동하여 json이 객체에 바인딩되는 것인지 파악해 보겠습니다. 먼저 IntelliJ에서 AbstractMessageConverterMethodArgumentResolver 클래스를 연 후, 중단점을 체크해 보았습니다.먼저 register uri를 가지는 컨트롤러와 해당 컨트롤러로 회원 가입 요청을 하겠습니다. 

/**
 *
 * 회원 가입 요청
 * BAD_REQUEST: 400 (요청한 파라미터의 타입 에러 혹은 바인딩 에러)
 * CONFLICT: 409 (요청한 회원가입 이메일 이미 존재)
 * OK: 200 (가입 완료)
 *
 * @param formRegisterUserDto
 * @param bindingResult
 * @return
 */
@PostMapping("/register")
public ResponseEntity formRegister(@Valid @RequestBody FormRegisterUserDto formRegisterUserDto,
                                   BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
    }

    if (!userService.registerForm(formRegisterUserDto, passwordEncoder)) {
        return new ResponseEntity<>(HttpStatus.CONFLICT);
    }

    return new ResponseEntity<>(HttpStatus.OK);
}

messageConverters는 request 요청한 dto가 컨버터 타입이 맞을 때까지 반복문이 실행됩니다.

만약 컨버터가 작동가능한 상태가 아니라면 GenericConverter는 null값이 됩니다.

MappingJackson2HttpMessageConverter 가 선택되면, 해당 메시지에 Body가 있고 그 Body가 파싱 되어 Dto에 값이 처리됩니다. 

 

이후, contentType과 body를 설정한 후, body를 리턴하는 것을 확인할 수 있습니다.

 

따라서, 정리하면 @RequestBody는 클라이언트에게 Json으로 request 요청을 받게 되면, MappingJackson2HttpMessageConverter가 해당 컨트롤러에서 설정한 Dto로 객체를 바인딩해서 처리하는 것을 확인할 수 있습니다.

 

 

이외에도 다양한 어노테이션이 존재하는데, 다음 포스팅에 또 정리하는 시간을 가지도록 하겠습니다.

읽어주셔서 감사합니다~!

 

참조: https://frogand.tistory.com/163

 

[Spring] @RequestBody vs @ModelAttribute

이전 글 https://frogand.tistory.com/114 [Spring] @RequestBody, @RequestParam, @ModelAttribute의 차이 Client에서 받은 요청을 객체로 바인딩하기 위해 사용하는 방법에는 총 @RequestBody, @RequestParam, @ModelAttribute 총 3가지

frogand.tistory.com

 

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

오늘은 향로님의 SpringBoot Json Api XSS Filter 적용하기 글을 읽고, XSS Filter를

직접 적용하며 배운 개념들과 과정을 정리하는 시간을 가져보았습니다.

 

먼저, 향로님 원본 글 주소입니다. (https://jojoldu.tistory.com/470

XSS의 개념, 왜 스프링 부트에서 XSS가 위험한 것일까? , XSS을 스프링 부트에서 방지하는 방법 순으로 정리하도록 하겠습니다.

 

1. XSS이란?

XSS(Cross-Site Scripting) 이란 SQL Injection과 함께 웹 상에서 가장 기초적인 취약점 공격 방법 중 하나로, 권한이 없는 사용자가 악의적인 용도로 웹 사이트에 스크립트를 삽입하는 공격을 의미합니다. 다른 웹사이트와 정보를 교환하는 식으로 작동하므로 사이트 간 스크립팅이라고 부릅니다.

XSS는 자바스크립트를 사용하여 공격하는 경우가 가장 많고, 사용자의 세션을 공격자의 서버로 전송하거나, 악성코드가 있는 페이지로 리다이렉트 시키는 방법으로 공격이 주로 행해집니다.

 

https://www.easymedia.net/Culture/EasyStory/index.asp?no=170&mode=view&IDX=1165&p=1

 

2. 왜 스프링 부트에서 XSS가 위험한 것일까? 

 

처음에, XSS Filter 글을 읽고 의아한 점이 생겼습니다. HTML 태그와 같은 마크업 언어는 자바와 다른 형식이므로 서버에서 단순히 문자열로 인식될 것이라고 생각을 했습니다. 하지만, 클라이언트와 확장해서 생각해보니 데이터를 문자열로 판단한다는 점이 XSS의 위험한 점이었다는 것을 알게 되었습니다. (제 개인적인 사견이므로, 해당 부분은 사실과 다를 수 있습니다.)

 

이를 코드와 예시로 파악해 보겠습니다.

만약, A라는 사람이 학교 데이터를 Post 요청해서 데이터베이스에 저장되었다고 가정한 후, B라는 사람이 학교 데이터를 Get 요청하여 클라이언트 화면에 학교 리스트가 보이는 상황을 가정해 보겠습니다.

 

@PostMapping("/insert")
public ResponseEntity insertSchool(@Valid @RequestBody SchoolSaveDto saveDto,
                                   BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return new ResponseEntity(HttpStatus.BAD_REQUEST);
    }

    Long schoolId = schoolService.save(saveDto);
    return new ResponseEntity(new ResponseDto<>(schoolId), HttpStatus.CREATED);
}

@GetMapping("/{schoolId}")
public ResponseEntity getSchoolOne(@PathVariable("schoolId") Long schoolId) {
    School school = schoolService.findOne(schoolId);
    return new ResponseEntity(school, HttpStatus.OK);
}

@Data
@NoArgsConstructor
static class ResponseDto<T> {
    T data;

    public ResponseDto(T data) {
        this.data = data;
    }
}

A라는 클라이언트가 POST로 정상적인 요청을 수행한다면, 결과는 다음과 같이 전달됩니다.

POST /api/v1/schools/insert HTTP/1.1

B라는 클라이언트가 GET으로 정상 요청을 수행한다면, POST로 저장된 값을 받아올 수 있습니다.

GET /api/v1/schools?schoolId=8

하지만, 만약 Json 형태의 Post 요청에 XSS 공격 형태의 값을 넣게 되면 어떻게 될까요? 바인딩 에러가 생기지 않는 String 타입의 필드에 <a href='www.naver.com'> 형태의 값을 넣고 Post 요청을 보내면 데이터베이스에 저장이 됩니다.

 

이후, GET /api/v1/schools?schoolId=9 HTTP/1.1 요청을 수행하면  schoolAddress에 해당 a태그가 전달되는 것을 볼 수 있습니다. 만약 서버에서 받은 데이터를 클라이언트에게 제공한다면 해당 태그로 인해 클릭 시 외부 사이트로 접근할 수 있는 상황이 생길 수 있습니다. 따라서 XSS 공격의 취약점이 발생할 수 있습니다.

 

3. JSON API에서 XSS 공격 방지 적용

 

향로님의 블로그를 바탕으로, 주어진 환경에 맞춰 진행하였습니다.

먼저 제 버전은 , SpringBoot 2.7.6 버전, 자바 11, Gradle입니다.

plugins {
   id 'java'
   id 'org.springframework.boot' version '2.7.6'
   id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'messenger'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

XSS 방지 필터는 naver 개발자 분들이 개발하신 라이브러리와 StringEsxapeUtils 의존성 주입을 받아야 합니다.

//xss
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.0'
implementation 'com.navercorp.lucy:lucy-xss:1.6.3'

//StringEscapeUtils
implementation 'org.apache.commons:commons-text:1.8'
package messenger.messenger.business.school.presentation.filter;

import com.fasterxml.jackson.core.SerializableString;
import com.fasterxml.jackson.core.io.CharacterEscapes;
import com.fasterxml.jackson.core.io.SerializedString;
import org.apache.commons.text.StringEscapeUtils;


public class HtmlCharacterEscape extends CharacterEscapes {

    private final int[] asciiEscapes;

    public HtmlCharacterEscape() {
        asciiEscapes = CharacterEscapes.standardAsciiEscapesForJSON();
        asciiEscapes['<'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['>'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['\"'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['('] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes[')'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['#'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['\''] = CharacterEscapes.ESCAPE_CUSTOM;
    }

    @Override
    public int[] getEscapeCodesForAscii() {
        return asciiEscapes;
    }


    @Override
    public SerializableString getEscapeSequence(int ch) {
        return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) ch)));
    }
}

해당 클래스는 특수문자 입력을 받게 되면, 다른 글자로 치환해주는 역할을 수행합니다. 이후,  objectMapper를 복사한 Converter용 objectMapper에 HtmlCharacterExscape를 의존성 주입받아 사용합니다.

 

 

4. 테스트코드 작성하기

TestRestTemplate를 사용하기 위해서는, @Autowired를 활용한 의존성 자동 주입을 받아야 합니다. 하지만, 자동으로 의존성 주입을 받을 수 없다는 에러가 발생했습니다. 

 

@SpringBootTest의 webEnvironment 속성은 테스트의 웹 환경을 설정하는 속성이며 기본값은 SpringBootTest.WebEnvironment.MOCK입니다.
WebEnvironment.MOCK은 실제 서블릿 컨테이너를 띄우지 않고 서블릿 컨테이너를 mocking 한 것이 실행됩니다.
스프링 부트의 내장 서버를 랜덤 포트로 띄우려면 webEnvironment를 SpringBootTest.WebEnvironment.RANDOM_PORT로 설정하면 됩니다. 이 설정은 실제로 테스트를 위한 서블릿 컨테이너를 띄웁니다.

 

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@ExtendWith(MockitoExtension.class)
class SchoolControllerTest {

//중략

@Autowired TestRestTemplate restTemplate;
@Test
    public void 학교_저장() throws Exception {

        //given
        SchoolSaveDto saveDto = new SchoolSaveDto("서울c 학교", "<a ref='www.naver.com'> </a>", "1233");

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", accessToken);

        HttpEntity<SchoolSaveDto> entity = new HttpEntity<>(saveDto, headers);

        //when
        ResponseEntity<Long> response = restTemplate
                .exchange(
                "/api/v1/schools/insert",
                HttpMethod.POST,
                entity,
                Long.class);

        School school = schoolService.findOne(response.getBody());

        //then
        Assertions.assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Assertions.assertThat(school.getSchoolAddress()).isNotEqualTo("<a ref='www.naver.com'> </a>");
        Assertions.assertThat(school.getSchoolAddress()).isEqualTo("&lt;a ref='www.naver.com'&gt; &lt;/a&gt;");

    }

restTemplates를 사용하기 위해서는 header 설정을 진행해야 합니다.

 

GIVEN
저는 로그인한 상태에서만 요청이 가능하기 때문에 헤더에 액세스 토큰을 추가하였고,
Application/json으로 요청을 받아 처리하였습니다.
컨트롤러가 Dto 타입의 요청을 받기 때문에, HttpEntity<SchoolSaveDto> entity = new HttpEntity<>(saveDto, header);를 설정하였습니다.
WHEN
restTemplate.exchange()는 url, method 방법, 요청과 헤더를 담은 HttpEntity, 반환 타입을 정합니다.
insert이기 때문에 post 요청을 진행하였고, 최종적으로 결과물은 학교 번호를 받으므로 Long.class로 설정하였습니다.
THEN
클라이언트의 POST 요청 성공에 따른, status 상태 코드를 CREATED (201)로 설정하였고, 
HtmlCharacterEscape 클래스를 주입받은 objectMapper가 제대로 작동했다면, 아래 테스트가 성공되어야 합니다.!

 

결과는. 성공한 것을 확인할 수 있습니다.!

 

오늘은 SpringBoot XSS 공격 방지 Filter를 처리하는 방법을 정리하였습니다.

다음에도 더 발전한 기술 블로그를 작성할 수 있도록 노력하겠습니다.

 

읽어주셔서 감사합니다~!

안녕하세요.! 회사와 함께 성장하고 싶은 KOSE입니다.

오늘은 크리스마스 동안 붙들었던 redis cache를 QueryDsl의  Page<T> 타입에 적용한 후기를 적어보고자 합니다.

(다들 연말 잘 보내세요!. 늦은 시간이지만, 너무나도 즐겁게 글을 작성할 수 있어서 행복합니다..ㅎㅎ!)

 

저는, Redis를  로그인에 사용되는 액세스 토큰 및 리프레쉬 토큰을 저장하는 저장소로, 두 번째는, 캐시화 처리를 위한 용도로 사용하였습니다. 토큰을 저장하는 용도의 사용은 다른 분들께서 자세히 적어주셔서 해당 과정은 에러 없이 해결했지만, 후자는 많은 에러가 발생했습니다. 다시 한번, 백엔드의 어려움과 경의로움을 경험할 수 있었습니다.

 

이 글은, Redis에 대한 정의, Local cache Vs global cache 비교, 에러 상황, 에러 해결 순으로 글을 작성하도록 하겠습니다.

글을 읽으시는 분들 중에 저와 비슷한 에러가 발생하신 분들이 있다면, 도움이 되셨으면 좋겠습니다.

1. Redis?

참조:&nbsp;https://devlog-wjdrbs96.tistory.com/374

 

Redis는 캐시 시스템으로 동일한 기능을 제공하면서 영속성,
다양한 데이터 구조와 같은 부가적인 기능을 지원합니다.

Redis는 모든 데이터를 메모리에 저장하고 조회하는 인메모리 데이터베이스입니다.

 

a. 인메모리 데이터베이스?

인메모리 데이터베이스는 보조 기억 장치를 사용하는 기존 데이터베이스에 비해 빠릅니다. (하드디스크 등) 

컴퓨터 중앙 처리 장치(CPU)는 주 메모리에 저장된 데이터만 직접 접속할 수 있습니다. 따라서, 주메모리 방식이 보조기억 장치보다 훨씬 빠르게 데이터를 처리할 수 있습니다.

 

b. 인메모리 데이터베이스와 디스크 기반 데이터베이스 차이점

디스크 기반 데이터베이스는 IO 작업을 수행하기 때문에, 디스크에서 쓰기/읽기 작업을 필요로 합니다. 디스크 기반 데이터 베이스는 데이터를 저장하는 구조가 복잡한데, 데이터 베이스에 데이터를 저장하기 전에 디스크 액세스가 효율적임을 먼저 확인합니다.

하지만, 인메모리 데이터베이스는 주 메모리에서 데이터를 무작위로 액세스 하는 것이 효율적이기 때문에 저장 구조가 간단합니다.

 

2. Local cache Vs Global cache

저는 이전 프로젝트에서 local cache의 일종인 caffeinCached를 사용한 경험이 있습니다. 그 당시에는 local cache와 global cache 차이점을 알지 못하고 redis는 토큰 저장소의 일종이라고만 생각을 했었습니다. 프론트분과 협업을 진행하는 과정에서 redis의 무궁한 기술을 접하게 되었고 두 캐시 방법의 차이점을 느끼게 되었습니다.

 

a. Local cache

 

참조:&nbsp;https://deveric.tistory.com/71

Local cache는 서버마다 캐시를 따로 저장합니다. 즉 여러 대의 서버가 존재한다면, 다른 서버의 캐시를 참조하기 어렵습니다. 이는 곧 정합성 문제와 직결되는데, 다중 서버를 운영한다면 같은 로직의 데이터 요청이 들어올 때, 서버마다 캐시가 참조가 어려우므로 데이터 정합성 문제가 발생할 수 있습니다.

이는 제가 경험해보지 못하여서 확실하지 않지만, 제 생각은 local cache는 사용자의 요청에 따라 캐시가 생성되고 인메모리에 저장되는데, 다른 서버에 저장된 캐시는 확인하기 어려우므로 다시 캐시가 생성될 수 있습니다. 

또한, a라는 서버에 부하가 생길 시 로드밸런서가 a 서버 요청을 b 서버로 위임하게 된다면, 로그인을 다시 해야 하는 상황이 생길 수 있다고 생각합니다. 하지만, 서버 내에 캐시가 저장되므로 속도가 빠르다는 장점이 있습니다.

 

b. Global cache 

 

https://deveric.tistory.com/71

global cache는 여러 서버에서 캐시 서버를 참조하는 방식입니다. 이는 네트워크 트래픽을 사용하므로 로컬 캐시보다 느리다는 단점이 있으나, 서버 간 데이터 공유가 쉽습니다.

먼저, global cache는 외부 캐시 저장소에 접근하여 데이터를 가져오므로 네트워크 I/O 비용이 발생합니다. 하지만, 서버 인스턴스가 추가되더라도, 동일한 비용만 요구하므로 서버가 고도화될수록 더 높은 효율을 발휘됩니다.

또한, 위의 local cache와는 다르게 모든 서버의 인스턴스가 동일한 캐시 저장소에 접근하므로 데이터의 정합성이 보장됩니다.

(참조: https://souljit2.tistory.com/72)

 

 

3. Redis 캐싱 에러 발생

저는 Redis를 토큰 저장소와 자주 사용하지만 변할 가능성이 적은 데이터에 활용하고자 하였습니다. 학교 찾기와 같은 로직은 데이터가 잘 변하지 않지만, 매번 요청마다 데이터베이스에서 데이터를 가져와 클라이언트에 제공하는 과정이 서버에 많은 부하를 주리라 생각하였습니다. 따라서, 학교 찾는 서비스 로직에 Redis를 적용하고자 하였습니다.

 

먼저 Redis를 사용하기 위해선, bulild.gradle에 redis 관련 라이브러리를 의존받고, yml에 포트 및 호스트 등록, redis를 스프링 빈으로 사용하기 위해 RedisConfig 등록이 필요합니다.

// build.gradle

//redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
//application.yml

spring:
  redis:
    host: localhost
    port: 6379
@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @SuppressWarnings("deprecation")
    @Bean
    public CacheManager cacheManager() {
        RedisCacheManagerBuilder builder = fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration = defaultCacheConfig()
                .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(30));
        builder.cacheDefaults(configuration);
        return builder.build();
    }

}

serializeValuesWith(fromSerializer(new GenericJackSon2JsonRedisSerializer())) 는  객체를 Json 타입으로 직렬화/역직렬화를 수행하여 binary 데이터 형태를 String으로 혹은 그 반대로 전환하는 역할을 수행해 줍니다.

entryTtl은 해당 캐시의 지속시간을 설정할 수 있습니다.

 

SchoolSearchDto로 클라이언트에게 학교 찾는 요청을 받는 Dto입니다.

@Data
@NoArgsConstructor
public class SchoolSearchReqDto extends BaseDefaultPageable {

    private String schoolName;
    private String schoolAddress;

    @Builder
    public SchoolSearchReqDto(String schoolName, String schoolAddress, Integer page, Integer size) {
        super(page, size);
        this.schoolName = schoolName;
        this.schoolAddress = schoolAddress;
    }
    
}

해당 소스는, 학교 이름 혹은 학교 주소를 동적으로 입력받아 쿼리를 생성하는 쿼리메서드입니다.

@Override
    public Page<SchoolSearchDto> searchSchoolsPageComplex(SchoolSearchCondition condition, Pageable pageable) {

        List<SchoolSearchDto> content = query
                .select(
                        new QSchoolSearchDto(
                                school.id,
                                school.schoolName,
                                school.schoolAddress))
                .from(school)
                .where(
                        schoolNameContains(condition.getSchoolName()),
                        schoolAddressContains(condition.getSchoolAddress())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = query
                .select(school.count())
                .from(school)
                .where(
                        schoolNameContains(condition.getSchoolName()),
                        schoolAddressContains(condition.getSchoolAddress())
                );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

 

    /**
     *
     * 학교 검색
     * schoolSearchReqDto를 입력받아, name, address로 동적 쿼리 생성
     * schoolSearchReqDto의 page 관련 파라미터를 받아 pageable 생성
     *
     * @param reqDto
     * @return
     */
    @Cacheable(value = "Page<SchoolSearchDto>", key = "#reqDto.schoolName.hashCode()", cacheManager = "cacheManager", unless = "#reqDto.schoolName.hashCode() == ''")
    public Page<SchoolSearchDto> getSchoolSearchDto(SchoolSearchReqDto reqDto) {

        SchoolSearchCondition condition = SchoolSearchCondition.of(reqDto.getSchoolName(), reqDto.getSchoolAddress());
        Pageable pageable = PageRequest.of(reqDto.getPage(), reqDto.getSize(), Sort.by("id"));
        return schoolQueryRepository.searchSchoolsPageComplex(condition, pageable);

    }

SchoolQueryRepositoryImpl의 의존성 주입을 받은 SchoolService는 클라이언트의 SearchReqDto를 파라미터로 받아서 Page<SchoolSeachDto>를 리턴하는 메서드입니다. 

 

    @GetMapping("/search")
    public ResponseEntity getSchoolsSearchRes(@RequestBody SchoolSearchReqDto reqDto) {
        Page<SchoolSearchDto> schoolSearchDto = schoolService.getSchoolSearchDto(reqDto);
        return new ResponseEntity(new RestPage<>(schoolSearchDto), HttpStatus.OK);
    }

컨트롤러에서는 해당 데이터와 상태코드를 제공합니다.

 

이후, postman에서 해당 로직을 검증한 결과, 아래와 같은 문제가 발생하였습니다.

 

4. 에러 해결 과정

서비스 로직에 놓았던 @Cacheable 어노테이션을 컨트롤러에 위치하는 등 다양한 방법을 사용했지만, 이와 같은 상황이 발생했습니다. 구글링에 검색한 결과, 직렬화 과정에서 SchoolSearchDto와 PageIm의 기본생성자가 없어서 발생한 문제였습니다.

 

따라서, PageIm <T>를 상속받는 RestPage <T>를 생성하였습니다.

@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class RestPage<T> extends PageImpl<T> {
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public RestPage(@JsonProperty("content") List<T> content,
                    @JsonProperty("number") int page,
                    @JsonProperty("size") int size,
                    @JsonProperty("totalElements") long total) {
        super(content, PageRequest.of(page, size), total);
    }

    public RestPage(Page<T> page) {
        super(page.getContent(), page.getPageable(), page.getTotalElements());
    }
}

또한, SchoolSearchDto 역시 기본 생성자 어노테이션을 추가하였습니다.

@Data
@ToString
@NoArgsConstructor
public class SchoolSearchDto {

    private Long id;
    private String schoolName;
    private String schoolAddress;

    @Builder
    @QueryProjection
    public SchoolSearchDto(Long id, String schoolName, String schoolAddress) {
        this.id = id;
        this.schoolName = schoolName;
        this.schoolAddress = schoolAddress;
    }
}

그 결과, postman의 응답결과와 redis-cli의 value가 잘 전달되고 저장된 것을 확인할 수 있습니다.

 

5. Why 기본 생성자?

 

과거에 이펙티브 자바를 공부할 때, 리플렉션이라는 개념을 공부한 경험이 있습니다. 그 당시에 와닿지 않아서 넘겼는데, 리플렉션이 기본생성자와 많은 연관이 있음을 알게 되었습니다.

Reflection은 접근 제어자와 상관없이 클래스 객체를 동적으로 생성(런타임 시점)하는 자바 API입니다. 자바 리플렉션은 구체적인 클래스 타입을 알지 못해도, 클래스의 메서드, 타입, 변수들에 접근할 수 있도록 하는 기능을 수행하는데, 기본 생성자가 없다면 자바 리플랙션이 객체를 생성할 수 없습니다.

스프링 데이터 Jpa도 동적으로 객체를 생성하는 리플렉션을 사용하므로 Dto에 기본 생성자가 존재하지 않아서 발생한 에러입니다.


한 가지 의문점이 들었습니다. Dto에 기본 생성자를 추가했는데, "왜 다시 에러가 났을까?"
그리고, "RestPage <T> 클래스로 Page<SchoolSearchDto>를 감쌌을 때는 왜 에러가 나지 않았을까?"입니다. 
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)

이에 대한 해답은  어노테이션에 있었습니다.

Jackson의 @JsonCreator를 이용하면 인자가 없는 기본 생성자와 set메서드가 없이도 객체를 생성할 수 있는 경이로운 기능이 숨겨져 있었습니다.

따라서, 해당 어노테이션을 사용하면 기본 생성자 문제가 해결되므로, 이상 없이 이 문제를 해결할 수 있었습니다.

 

정말 배움에는 끝이 없는 것 같습니다. 

하지만, 배움으로 더 발전하고, 성장할 수 있어서 행복합니다.

긴 글 읽어주셔서 감사드립니다. 다들 좋은 하루 되십시오.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
velog를 사용하다, 체계적으로 글을 작성해보고 싶어서, tistory로 옮긴 후 이번이 두 번째 글입니다.

코드 리팩토링 과정에서 아무렇지 않게 사용했던, @RequestHeader를 보며, 순간 `아차` 하는 생각이 들었습니다.
HttpRequest 요청받을 때, 꼭 필요한 Header라는 것을 명시하고, 필터링하기 위해 사용했지만,
해당 필터는 이미 JwtFilter에 적용된 상태였습니다. Controller를 리팩토링하면서, 해당 어노테이션을 제거하며
해당 어노테이션의 쓰임새에 대해 고찰해보는 시간을 가지게 되었습니다.

1. @RequestHeader

@RequestHeader Annotation은 HTTP 요청 헤더 값을 컨트롤러 메서드의 파라미터로 전달한다(메서드 파라미터가 String가 아니라면 타입변환을 자동으로 적용한다)
만약 헤더가 존재하지 않으면 에러가 발생하며, required 속성을 이용해 필수여부를 설정할 수 있다.
또한 defaultValue 속성을 이용해 기본 값도 설정 가능하다.

@RequestHeader은 이처럼 사용자 환경에서 커스텀하게 사용할 수 있는 이점이 있습니다.

 

2. 해당 어노테이션을 삭제한 과정

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

        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();

        if (isLoginCheckPath(requestURI)) {

            log.info("jwt = {}", jwt);

            if (
                    StringUtils.hasText(jwt)
                    && tokenProviderImpl.validateToken(jwt)
                    && isNotLogoutAccessToken(jwt)
            ) {
                Claims claims = tokenProviderImpl.getClaims(jwt);
                log.info("claims.getExpiration() = " + claims.getExpiration());

                Authentication authentication = tokenProviderImpl.getAuthentication(jwt);

                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("authentication.getName() = {}", authentication.getName());

            } else {
                log.info("유효한 JWT 토큰이 없습니다, uri = {}", requestURI);
                return ;
            }

        }

        filterChain.doFilter(request, response);

    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        log.info("bearToken = {}", bearerToken);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }

 

해당 코드는 JwtFilter.class 로 클라이언트 요청이 발생하면 수행되는 필터입니다.

아래 그림과 같이 클라이언트에게 Request 요청이 발생하면 필터를 거친 후, 디스패처 서블릿에 도달합니다.

 

Filter 작동 과정

a. Why ?

필터를 사용하는 이유는 디스패처 서블릿에 요청 과부하를 줄여주는 역할을 수행합니다. 사용자가 설정한 Filter 조건을 만족하지 않는다면 doFilter 메소드가 호출되지 않고, return 하여 해당 요청의 처리를 중단하여, 예외 처리가 이루어질 수 있도록 합니다.

스프링은 filter가 chain 형태로 복잡하게 이뤄진 것을 확인할 수 있습니다. 제가 진행하고 있는 토이 프로젝트에는 이처럼 다양한 필터가 존재하는 것을 확인할 수 있습니다. (현재, JwtFilter는 8번째 인덱스에 속해있습니다.)

따라서, 디스패처 서블릿의 부담을 덜어주어 서버 과부화를 줄이는데 많은 역할을 수행하는 것이 filter라고 할 수 있습니다.

 

b. How ?

필터를 사용하는 방법은,  OncePerRequestFilter 혹은 해당 클래스의 부모 클래스를 상속받거나, Filter 인터페이스를 구현함으로써 사용할 수 있습니다. 저는 OncePerRequestFilter를 상속받아 사용하는데, 이는 필터 작동 원리가

그림 <Filter 작동 과정>의 모습처럼 Request -> 처리 -> Response로 이어지는 과정에서 두 번 처리되는 것을 볼 수 있습니다. OncePerRequestFilter는 해당 요청이 두 번 실행되지 않도록 하는 Filter 구현체 입니다.

 

두 가지 방법을 활용하여 해당 메소드를 재정의 혹은 구현하게 된다면, doFilterInternal 메소드를 오버라이딩 하게 됩니다.

이 메소드에서 request에 대한 헤더를 파싱하거나 검증하는 과정을 수행할 수 있습니다.

이후, 해당 필터를 Bean 등록하여 사용할 수 있도록 Config 클래스를 생성합니다. 스프링 Security5 이상부터는, Security 설정하는 방법이 바뀌었는데, 이에 대한 글도 추가로 작성하도록 하겠습니다.

맨 아래의 .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)는 UsernamePasswordAuthenticationFilter가 수행되기 전에 해당 필터를 적용하라는 것 입니다.

@Bean
    SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http,
                                                  TokenProviderImpl tokenProviderImpl) throws Exception {

        http
                .httpBasic().disable()
                .csrf().disable()

                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                .and()
                .authorizeRequests((requests) -> requests
                .antMatchers(
                        "/resources/**",
                        "/api/v1/register",
                        "/api/v1/login",
                        "/"
                )
                .permitAll()
                .anyRequest().authenticated())
                
                .formLogin().disable()

                .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(
                        userInfoEndpointConfig -> userInfoEndpointConfig
                                .userService(customOAuth2UserService)
                                .oidcUserService(customOidcUserService)))

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();

    }

c. Where ?

해당 JwtFilter에서는 resolveToken 메소드에 해당 검증 내용이 담겨 있습니다. AUTHORIZATION_HEADER 파이널 상수는 "Authorization" 헤더의 문자열을 할당하였습니다.  따라서, JwtFilter가 적용되는 과정에서 해당 토큰 값만 파싱한 후, 토큰 검증하여 해당 클라이언트의 요청이 유효한 유저의 요청인지 판단합니다. 따라서, Controller에서 해당 헤더를 판단하게 된다면 불필요한 체킹이 될 수 있습니다.

private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

    log.info("bearToken = {}", bearerToken);

    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }

    return null;
}

 

3. @RequestHeader 사용처

그렇다면 해당 어노테이션은 어디에 사용할까요? 다양한 사용처가 있겠지만,

저는 RefreshToken을 헤더에 담아서 사용할 때, 해당 어노테이션이 중요한 역할을 수행할 수 있다고 생각합니다.

만약, 사용자에게 만료 기간 전에 RefreshToken을 재발급 해야 하거나, 사용자가 먼저 재발급 요청을 하게 될 때 필터를 통과한 후, 컨트롤러 해당 RefreshToken이 담겨있다면, 이를 확인해서 토큰을 재발급하는 과정이 수행될 수 있습니다.

 

이처럼 토큰을 재발급해야하는 상황이 생긴다면, 해당 해더에 "RefreshToken"이라는 커스텀 헤더를 요청받아, 로직을 수행할 수 있습니다. Filter에 해당 요청을 받는 로직도 생각해보았지만, refreshToken은 매번 검증이 필요한 토큰이 아니라 판단하여, 재발급 요청 시에만 해당 토큰을 검증하면 된다고 생각하였습니다. 따라서, 효율성 측면에서 해당 요청에만 커스텀 헤더를 적용하였습니다.

 

아무렇지 않게 사용했던 것들을 다시 자세히 살펴보는 과정에서, 정말 배울 점이 많다는 것을 느끼게 되었습니다.

긴 글 읽어주셔서 감사드립니다.!

안녕하세요. 회사와 함께 성장하고 싶은 Kose입니다.

오늘은 크리스마스 이브입니다. 모두 행복한 크리스마스 보내시기 바랍니다.!
이번 글은 Spring에서 Mockito 라이브러리와, Postman을 활용한 Controller 테스트 코드 작성에 대해 논의해보고자 합니다.

Mock
모의 객체(Mock Object)란 주로 객체 지향 프로그래밍으로 개발한 프로그램을 테스트할 경우 테스트를 수행할 모듈과 연결되는 외부의 다른 서비스나 모듈들을 실제 사용하는 모듈을 사용하지 않고 실제의 모듈을 "흉내"내는 "가짜" 모듈을 작성하여 테스트의 효용성을 높이는 데 사용하는 객체입니다. 사용자 인터페이스(UI)나 데이터베이스 테스트 등과 같이 자동화된 테스트를 수행하기 어려운 때 널리 사용됩니다. 위키백과, Mock Object 인용

 

평소에 테스트 코드를 짤 때, 눈으로 직접 확인하고 계속 틀려보고 오류를 수정하는 작업을 하면서 많이 성장할 수 있다고 생각하였습니다. 하지만, 외부의 요청이 있어야 하는 경우, 직접 코드를 작성하는 데 한계가 있을 수 있습니다. 이럴 때, Mock 객체를 활용하여 가짜 요청을 생성한 후, 작성한 코드의 품질을 평가한다면 코드 작성의 효율이 높아질 수 있습니다.

저는, 학과 내 개발에 관심이 있는 학우와 팀을 구성하여 간단한 메신저 프로젝트를 진행하였습니다. 백엔드를 구성하는 과정에서 작성한 로직을 바탕으로 예시를 작성하겠습니다.

1. SchoolController 구성

@RestController
@Slf4j
@RequestMapping("/api/v1/schools")
@RequiredArgsConstructor
public class SchoolController {

    private final SchoolService schoolService;

    @GetMapping("/search")
    public ResponseEntity getSchoolsSearchRes(@RequestHeader("Authorization") String accessToken,
                                              @RequestBody SchoolSearchReqDto schoolSearchReqDto) {

        Page<SchoolSearchDto> schoolSearchDto = schoolService.getSchoolSearchDto(schoolSearchReqDto);

        return new ResponseEntity(schoolSearchDto, HttpStatus.OK);
    }

}

해당 컨트롤러는 AccessToken을 Authorization 헤더로 받고, School을 검색하기 위한 SearchReqDto를 json 형태로 받아, QueryDsl을 이용하여 페이징 처리 하여 값을 받아오는 컨트롤러입니다.

2. TestClass 작성

@SpringBootTest
@Transactional
@ExtendWith(MockitoExtension.class)
class SchoolControllerTest {

    @Autowired UserService userService;
    @Autowired AuthService authService;
    @Autowired AuthorityService authorityService;
    @Autowired PasswordEncoder passwordEncoder;
    @Autowired SchoolService schoolService;
    @Mock SchoolController schoolController;

    static Users user;
    static List<Authority> authorities;
    static TokenAuthDto tokenAuthDto;
    static String accessToken;

    private MockMvc mockMvc;

해당 컨트롤러는 로그인이 필요하므로,
유저 가입 - userService (유저 패스워드 인코딩 - passwordEncoder)
권한 부여 - authorityService
토큰 발급 - authService
학교 찾기 - schoolService
빈으로 등록된 클래스를 의존성 주입받습니다.

그리고, Mock 라이브러리로 테스트가 필요한 SchoolController를
Mock빈 주입 받도록 합니다.

3. BeforeEach method

@BeforeEach
public void dbInit() {
        userService.registerForm(FormRegisterUserDto.builder().email("k@naver.com").username("kose").password("1234").build(), passwordEncoder);
        user = userService.findByEmail("k@naver.com");
        authorities = authorityService.findAuthorityByUser(user);

        tokenAuthDto = authService.createFormTokenAuth("k@naver.com", authorities);
        accessToken = "Bearer " + tokenAuthDto.getAccessToken();

        mockMvc = MockMvcBuilders.standaloneSetup(schoolController).build();

        saveSchool();
    }
    
private void saveSchool() {

        String[] school = {"서울A대학교", "서울B대학교", "인천A대학교"};
        String[] address = {"서울시 A구", "서울시 B구", "인천시"};
        String[] regisNum = {"123", "124", "125"};

        for (int i=0; i< school.length; i++) {
            schoolService.save(new SchoolSaveDto(school[i], address[i], regisNum[i]));
        }
    }
    
    private SchoolSearchReqDto requestDto() {
        return SchoolSearchReqDto.builder().page(0).size(10).schoolName("서울").build();
    }    

@BeforeEach 빈은, 테스트 클래스에 작성된 개별 Test 메소드가 실행되기 전에 실행되어 각 테스트의 개별화를 돕습니다.

해당 dbInit() 회원가입 및 로그인 로직, 학교 데이터 저장을 위해 설정한 메소드입니다.
사용자가 ID/PASSWORD 로그인을 이용하면, 회원 가입 시 "ROLE_USER"을 부여받고 로그인에 성공하여 토큰을 부여받습니다. accessToken은 Http Get 요청에 Header로 사용하기 위해 "Beaer "를 포함한 문자열로 static accessToken에 할당합니다.

MockMvc는 웹 애플리케이션을 애플리케이션 서버에 배포하지 않고도 스프링 MVC의 동작을 재현할 수 있는 클래스입니다.
MockMvcBuilders.standaloneSetup().build()를 활용하여 MockMvc 클래스의 인스턴스를 static mockMvc에 할당합니다.

3. 학교_검색 method

@Test
    public void 학교_검색() throws Exception {

        //given
        SchoolSearchReqDto reqDto = requestDto();
        Page<SchoolSearchDto> resDto = schoolService.getSchoolSearchDto(reqDto);

        //when
        ResultActions resultActions = mockMvc.perform(
                MockMvcRequestBuilders.get("/api/v1/schools/search")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(reqDto))
                        .header("Authorization", accessToken)
        );

        //then
        resultActions.andExpect(status().isOk());

    }

해당 메소드는, 실제 테스트를 구현하는 메소드입니다.

Given
requestDto() 메소드를 호출하여 reqDto를 생성한 후, 의존성 자동 주입을 설정한 schoolService의 gerSchoolSearchDto() 메소드를 호출 합니다. 해당 메소드는 학교 이름, 학교 주소 파라미터를 동적으로 입력받아 학교 관련된 학교를 제공하는 QueryDsl을 활용한 동적 쿼리입니다.

When
MockRequestBuilders.Get()은 mockMvc를 수행할 때, 요청하는 Http method입니다. 컨트롤러에 GetMapping으로 설정하였으므로, MockMvcRequestBuilders의 httpMethod를 설정하는 메소드도 get으로 설정하여야 합니다.

contentType은 클라이언트에게 json으로 입력받기 때문에 MediaType.APPLICATION_JSON을 설정합니다.

중요한 부분은 Header입니다. 처음 Controller에서
@RequestHeader("Authorization) String accessToken 을 설정하였습니다.
해당 메소드가 실행되기 위해서는 header에 유효한 accessToken을 Authorization에 설정하여 HttpRequest 요청을 수행해야 합니다.

then
andExpect()는 해당 요청에 대한 상태 및 content 결과를 검증할 수 있는 결과 메소드 입니다. 저는 해당 요청에 대한 성공 상태코드로 200을 설정하였으므로 isOK()로 설정하였습니다. 그뿐만 아니라, andExpect()로 content 값도 확인할 수 있는데, 타입 에러가 발생하여…. 이 부분은 Mock을 더 공부한 후에 적용하도록 하겠습니다.

 

4. Postman 검증

서버를 가동한 후, 회원가입 -> 로그인 -> 학교 찾기 Http 요청 순서로 진행하였습니다.

먼저, json 형태로 회원 가입 요청을 수행합니다.


회원가입에 성공 후, 로그인을 요청합니다.


Tests 목록에 해당 요청에 대한 결과를 테스트 코드 형태로 작성할 수 있습니다.
저는 요청 결과 상태코드 pm.response.to.have.status(200)으로 검증하였습니다.

var data = JSON.parse(responseBody);
pm.environment.set("accessToken", data.accessToken)
pm.environment.set("refreshToken", data.refreshToken)

해당 코드는 서버에서 전달한 Token 결과를 클라이언트의 Header에 저장하기 위해 설정한 코드입니다. 백엔드에서 프론트로 Token을 제공하면, 클라이언트는 해당 토큰을 쿠키나 세션 로컬 스토리지 등에 저장합니다.
postman은 environment.set을 호출하여 key-value 형태로 저장하여 다음 요청에 이를 활용할 수 있습니다.

학교 찾기 요청을 위해 Authorization 목록의 Type을 Bearer Token으로 설정한 후 {{key}}를 설정하면 위에 설정한 accessToken을 jwt 토큰으로 이용합니다.


헤더 설정 후, json으로 page, size, schoolName을 설정하여 요청을 보냅니다.


postman에서 원했던 결과를 확인할 수 있습니다.

 

5. 추가 공부할 내용

mock 객체를 활용하여 테스트 코드를 작성하면서 에러가 발생했던 부분이 있습니다.

resultActions.andExpect(status().isOk());

이 부분에 다른 추가 검증 내용을 작성하고 싶은데, ResultActions 클래스의 메소드 요청 파라미터를 올바르게 이해한 후에 추가 검증을 구현하여 글로 남기겠습니다.

또한, @Mock을 적용한 서비스 로직, @Mock을 적용한 서비스를 주입받고자 하는 @InjectMocks을 선언한 컨트롤러에 doReturn().when()을 적용할 때, 아래 에러가 발생하였습니다.

please remove unnecessary stubbings or use 'lenient' strictness. more info: javadoc for unnecessarystubbingexception class.

해당 원인은 불필요한 스터빙을 하지 않도록 되어있는데, 현재 코드에 쓰이지 않는 스터빙을 해놨기 때문에 발생한 에러입니다. 이에 대한 해결책으로 Mock 객체를 활용하지 않아도 되는 Service는 의존성 주입을 받아 진행했습니다. 이 해결책이 올바른지 궁금하여 추가 공부를 더 진행하고자 합니다.

오늘도 즐거운 공부 시간이었습니다.
감사합니다.!

+ Recent posts