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

 

이번 포스팅은 백준 10844번의 계단 오르기 문제를 해결한 과정을 정리하고자 합니다.

 

해당 문제는 N자리 숫자의 계단 수를 구하는 문제입니다.

 

1. 문제 해결 과정

1) n == 1일 때,

- 한자리 숫자의 경우 1 ~ 9까지 총 9개가 계단수입니다.

 

2) n == 2 일 때,

- 두 자리 숫자의 경우,  총 17개입니다.

12, 23, 34, 45, 56, 67, 78, 89 (8개)

10, 21, 32, 43, 54, 65, 76, 87, 98 (9개)

 

3) n == 3일 때,

- 세 자리 숫자의 경우, 총 32개입니다.

123, 234, 345, 456, 567, 678, 789 (7개)

987, 876, 765, 654, 543, 432, 321, 210 (8개)

101, 212, 323, 434, 545, 656. 767, 878, 989 (9개)

121, 232, 343, 454, 565, 676, 787, 898 (8개)

 

이 문제에서는 n번째 자리에 위치한 숫자는

(n - 1) 번째 자리 숫자보다 1 작은 수가 나올 수 있는 경우의 수  + (n -1) 번째 자리 숫자보다 1 큰 수가 나올 수 있는 경우의 수로

DP를 구현할 수 있습니다.

이를 그림으로 확인하면 다음과 같습니다.

따라서, 이를 알고리즘화 하면 아래의 코드로 구현할 수 있습니다. 

stair[i][j] = stair[i - 1][j - 1] + stair[i - 1][j + 1]

 

2. 주의할 점 

하지만, 주의할 점은 0보다 작은 자연수는 없으므로 자리 숫자에 0이 있으면 경우의 수는 (위치한 자릿수 - 1)의 1 큰 수의 경우만 고려해야 합니다.

stair[i][0] = stair[i - 1][1]

또한, 9는 9보다 1이 큰 한자리 숫자는 없으므로, (위치한 자리수 - 1)의 1 작은 수의 경우만 고려해야 합니다.

stair[i][9] = stair[i - 1][8]

 

마지막으로, 해당 알고리즘 결과를 제출하는데 여러 번 오답이 발생했습니다.

그 이유는, 최종 결과를 도출하는 과정에서 중간에 이미 long의 범위를 벗어나서 올바른 값을 도출하지 못했기 때문입니다.

(자바 long의 범위는 -2^63 ~ 2^63 -1)입니다.

 

해결책은 모듈러 연산을 적용하는 것입니다.

 

모든 경우의 수는 마지막 자릿수의 1부터 ~ 9까지의 경우를 더하면 완성이 됩니다. 이후, 10억으로 나눠도 되지만,

연산 가능 범위를 벗어날 수 있으므로, 모듈러 연산을 적용하여 각 배열에 저장할 때마다 MOD로 나머지 연산을 해주고 

최종적으로 마지막 숫자들을 더한 후, 다시 MOD로 나머지 연산을 해주면 동일한 결과를 얻을 수 있습니다.

        long result = 0L;
        for (int i = 0; i < 10; i++) {
            result += stair[N][i];
        }
(A + B) % p = ((A % p) + (B % p)) % p

 

3. 최종 소스 코드

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;

public class Main10844 {

    static final long MOD = 1000000000;
    static int N;
    static long[][] stair;

    public static void main(String[] args) throws IOException {

        BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));

        N = Integer.parseInt(bf.readLine());
        stair = new long[N + 1][10]; // (n 자리수), (0 ~ 9)

        long result = doStairDp();
        System.out.print(result % MOD);
    }

    static long doStairDp() {

        for (int i = 1; i<10; i++) {
            stair[1][i] = 1; // 첫번째 자리수가 i 일때는 모두 1
        }

        for (int i = 2; i <= N; i++) {

            for (int j = 0; j < 10; j++) {

                if (j == 0) {
                    stair[i][0] = stair[i - 1][1] % MOD;
                }

                else if (j == 9) {
                    stair[i][9] = stair[i - 1][8] % MOD;
                }

                else {
                    stair[i][j] = stair[i - 1][j - 1] + stair[i - 1][j + 1] % MOD;
                }
            }
        }

        long result = 0L;
        for (int i = 0; i < 10; i++) {
            result += stair[N][i];
        }

        return result;
    }
}

이상으로, 백준 10844 계단 오르기 문제 해결 과정 정리를 마치겠습니다.

감사합니다.!

 

문제 출처:

https://www.acmicpc.net/problem/10844

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

이번 포스팅은 백준 사이트의 2579번 계단 오르기 문제를 해결한 과정을 정리하려고 합니다.

 

해당 문제는 다음과 같습니다.

 

계단 오르기 게임은 계단 아래 시작점부터 계단 꼭대기에 위치한 도착점까지 가는 게임이다. <그림 1>과 같이 각각의 계단에는 일정한 점수가 쓰여 있는데 계단을 밟으면 그 계단에 쓰여 있는 점수를 얻게 된다.

 

<문제 규칙>

  1. 계단은 한 번에 한 계단씩 또는 두 계단씩 오를 수 있다. 즉, 한 계단을 밟으면서 이어서 다음 계단이나, 다음 다음 계단으로 오를 수 있다.
  2. 연속된 세 개의 계단을 모두 밟아서는 안 된다. 단, 시작점은 계단에 포함되지 않는다.
  3. 마지막 도착 계단은 반드시 밟아야 한다.

해당 문제는 DP를 활용하여 해결할 수 있습니다.

 

1. 문제 풀이 과정

 

밟은 계단은 파랑색, 안 밟은 계단은 회색으로 처리하였습니다.

 

  • if (n == 1) ? 
    n == 1이면, 무조건 계단을 밟아야 하므로, 10입니다.

 

  • if (n == 2) ?
    n == 2이면, 1번과 2번 계단을 밟아야 최대이므로, 답은 35입니다.

  • if ( n == 3)?
    n == 3이면, 1번 계단과 2번 계단 중, 큰 값을 선택하여 밟은 후, 3번째 계단을 밟아야 합니다.

  • if (n >= 4)?
    DP가 적용되기 시작하는 과정이라고 볼 수 있습니다.

 

  • if (n == 4)
    • 최댓값이라고 가정하여 밟을 수 있는 가지수는 크게 두가지 입니다. 연속해서 3개의 계단을 밟을 수 없으므로,
    • 1) 1번 + 3번 + 4번 => 10 + 15 + 25 = 50
    • 2) 1번 + 2번 + 4번 => 10 + 20 + 25 = 55
    • 즉, 1번 + 2번 + 4번을 밟는다면 55의 점수를 얻습니다.

 

  • if (n==5)
    • 1) 1번 + 2번 + 4번 + 5번 => 10 + 20 + 25 + 10 = 65
    • 2) 2번 + 3번 + 5번 => 20 + 25 + 10 = 55

 

이를 활용하여, 규칙을 확인할 수 있습니다.

만약 i 번째 계단이 있다면,

계단은 (i - 3) 번째의 얻을 수 있는 점수의 최대값 + (i - 1) 번째의 계단의 점수를 더한 값과 (i - 2) 번째의 점수의 최댓값을

비교하여 i번째 계단의 값을 더하면 최대 점수의 값을 구할 수 있습니다.

따라서, 30 + 25 + 10인 65가 됩니다.

 

따라서, 주요 로직은 다음과 같습니다. (scores는 i번째의 계단의 최대값을 기록하는 dp 스코어 배열, arr은 각 계단의 점수 배열)

scores[i] = Math.max(scores[i - 3] + arr[i - 1], scores[i - 2]) + arr[i];

 

2. 자바 알고리즘 코드 

최종 코드는 다음과 같습니다.

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;

public class Main2579 {

    static int n;
    static int[] arr;
    static int[] scores;

    public static void main(String[] args) throws IOException {

        BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));

        n = Integer.parseInt(bf.readLine()); // 입력받는 개수
        arr = new int[n]; // 입력받는 배열
        scores = new int[n]; // 점수 dp 스코어

        for (int i = 0; i < n; i++) {
            arr[i] = Integer.parseInt(bf.readLine());
        }

        long result = calculateDpScore();
        System.out.print(result);

    }

    static long calculateDpScore() {

        if (n == 1) {
            return arr[0];
        }

        else if (n == 2) {
            return arr[0] + arr[1];
        }

        scores[0] = arr[0];
        scores[1] = arr[0] + arr[1];
        scores[2] = Math.max(arr[0], arr[1]) + arr[2];

        for (int i = 3; i < n; i++) {
            scores[i] = Math.max(scores[i - 3] + arr[i - 1], scores[i - 2]) + arr[i];
        }

        return scores[n-1];
    }

}

이상으로 백준 2579번의 계단 오르기 알고리즘 정리를 마치겠습니다.

감사합니다.!!!

 

문제:

https://www.acmicpc.net/problem/2579

안녕하세요. 회사와 함께 성장하고 싶은 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입니다.

이번 포스팅은 디자인 패턴의 전략 패턴 (Strategy Patten)에 대해서 정리하는 시간을 가지려고 합니다.

 

1. 전략 패턴(Strategy Pattern)

객체들이 할 수 있는 행위 각각에 대해 전략 클래스를 생성하고 유사한 행위들을 캡슐화하는 인터페이스를 정의하며, 

객체의 행위를 동적으로 바꾸고 싶을 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 

방법을 의미합니다. 

전략패턴의 의도는 알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들어, 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있습니다.

https://ko.wikipedia.org/wiki/%EC%A0%84%EB%9E%B5_%ED%8C%A8%ED%84%B4

 

https://ko.wikipedia.org/wiki/%EC%A0%84%EB%9E%B5_%ED%8C%A8%ED%84%B4

Context라는 행위가 이루어질 때, 해당 행위를 하는 객체는 각각 조금씩 다른 전략을 사용해야 할 수 있습니다.

만약, 행위가 전략에 의존적이게 되어, 다른 객체가 생성이 될 때, 전략 자체를 수정해야 하는 상황이 생긴다면 개방-폐쇄 원칙(OCP)에 위배되는 상황이 생깁니다.

 

따라서, 전략 패턴을 사용하면, ContextStrategy 인터페이스에 행위에 대한 전략을 위임합니다. 이는 곧 확장에 유리하고, 유연한 설계가 가능해집니다. Strategy 인터페이스를 구현한 구현체를 전략으로 사용하게 된다면, 수많은 행위가 생겨나서 전략의 확장이 필요하더라도 구조를 바꾸지 않고 확장 설계가 가능해집니다.

 

>> OCP란?

소프트웨어 개발 작업에 이용된 많은 모듈 중에 하나의 수정을 가할 때, 그 모듈을 이용하는 다른 모듈을 줄줄이 고쳐야 한다면, 프로그램을 수정하기가 어렵습니다. 개방-폐쇄 원칙은 시스템의 구조를 올바르게 재조직하여 나중에 이와 같은 유형이 변경이 되어 더 이상의 수정을 유발하지 않도록 하는 것입니다.

 

OCP의 특징 

- 확장에 대해 열려있다. (모듈의 동작을 확장할 수 있다.)

- 수정에 대해 닫혀있다. (모듈의 소스 코드나 바이너리 코드를 수정하지 않아도 모듈의 기능을 확장하거나 변경할 수 있다)

 

 

2. 코드로 확인하기

@AllArgsConstructor
public class ContextV1 {

    private Strategy strategy;

    // execute를 strategy에 위임
    public void execute() {
        strategy.execute();
    }
}
public interface Strategy {

    // 전략 패턴에 사용할 인터페이스의 메서드
    void execute();
}
public class StrategyLogic1 implements Strategy {

    // 전략 첫 번째
    @Override
    public void execute() {
        System.out.println("전략 1번을 사용합니다.");
    }
}
public class StrategyLogic2 implements Strategy {

    // 전략 두 번째
    @Override
    public void execute() {
        System.out.println("전략 2번을 사용합니다.");
    }
}
    /**
     * 전략 패턴 사용
     */
    @Test
    public void strategyV1() throws Exception {
        //given
        StrategyLogic1 strategyLogic1 = new StrategyLogic1();
        
        // execute 행위에 대한 전략을 선택 하기 위해 생성자 생성
        ContextV1 contextV1 = new ContextV1(strategyLogic1);
        
        // 행위 실행
        contextV1.execute();
    }

 

+ 전략 패턴을 사용할 때, 필드로 의존관계 주입을 통해 전략을 먼저 설정한 후 실행하는 방법도 있고, 전략 패턴을 파라미터로 전달 받아서 처리하는 방법도 있습니다. 

 

3. 정리

전략 패턴은 특정 작업을 다양한 방식으로 수정하는 클래스를 선택한 후, 모든 알고리즘을 전략들이라는 별도의 클래스로 추출할 것을 제안합니다. 

콘텍스트(Context)라는 콘텍스트는 작업을 자체적으로 실행하는 대신 연결된 전략 객체에 위임합니다.

 

따라서, 

콘텍스트는, 사용할 전략을 전략 객체에 위임 ->위임받은 전략 객체가 해당 행위 실행하는 방식으로 활용할 수 있습니다.

 

감사합니다.!

 

참조:

스프링 핵심 고급 원리 - 영한님 강의 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/dashboard 

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

 

오늘 오전에는 BaekJoon의 우선순위 큐 문제를 풀어보았습니다. 이번 포스팅은 우선순위 큐의 정의, Comparable - comparator

백준 11286번 문제 해결 과정을 정리하도록 하겠습니다.

 

문제 출처: https://www.acmicpc.net/problem/11286

 

11286번: 절댓값 힙

첫째 줄에 연산의 개수 N(1≤N≤100,000)이 주어진다. 다음 N개의 줄에는 연산에 대한 정보를 나타내는 정수 x가 주어진다. 만약 x가 0이 아니라면 배열에 x라는 값을 넣는(추가하는) 연산이고, x가 0

www.acmicpc.net

 

1. Priority Queue의 특징

  • 높은 우선순위의 요소를 먼저 꺼내서 처리하는 구조
  • 내부 요소는 힙으로 구성되어 이진트리 구조로 이루어져 있습니다.
  • 내부구조가 힙으로 구성되어 있기에 시간 복잡도는 O(nlogn)
  • PriorityQueue에 객체를 추가하는 방법은 아래 표와 같습니다.
  예외 발생 값 리턴
추가 add offer (큐가 가득찬 경우 추가 실패 -> false)
삭제 remove poll
rjatk element peek

https://gmlwjd9405.github.io/2018/05/10/data-structure-heap.html

 

2 자료 구조 힙이란?

완전 이진트리의 일종으로 우선순위 큐를 위하여 만들어진 구조입니다.

여러 개의 값들 중에서 최댓값이나 최솟값을 빠르게 찾아내도록 만들어진 자료구조입니다.

힙은 반정렬(느슨한 정렬 상태)을 유지합니다.

-> 부모 노드의 키 값이 자식 노드의 키 값보다 항상 큰(작은) 이진트리입니다. 힙 트리는 중복된 값을 허용합니다.

 

https://gmlwjd9405.github.io/2018/05/10/data-structure-heap.html

자바 PriorityQueue에서 Heap 구현하기

자바는 PriorityQueue를 구현하기 위해 라이브러리를 호출해야 합니다. 만약 기본 타입이나, Wrapper 변수의 경우, 기본 정렬 방식을 사용할 수 있지만, 용자 정의 클래스 객체에 우선순위 큐를 적용하기 위해서는 비교(정렬) 방식을 정의해야 합니다. 따라서, 해당 문제에서는 Comparable 인터페이스를 구현하였습니다.

 

import java.util.PriorityQueue

 

Comparable과 Comparator 차이점

 

아래 블로그에 두 클래스에 대한 차이점을 명확하게 설명되어 있어서 참조 링크를 남기겠습니다.

요약하면, Comparable은 자기 자신과 매개변수 객체를 비교하는 것이고,

Comparator는 매개변수로 들어오는 두 객체를 비교하는 것입니다.

참조: https://st-lab.tistory.com/243

 

3. 문제 풀이

해당 문제는 절댓값 힙을 이용하여 입력값이 0인 경우, 절댓값이 가장 작은 값을 출력하고, x 이외의 숫자가 입력되면 힙에 추가하는 방식을 구현하는 문제입니다.

 

저는 위에 정리한 PriorityQueue와 Comparable 인터페이스를 구현하는 방법과 Comparator를 람다식으로 활용하여 해당 문제를 해결하였습니다. 특이점은,

AbsNumber 객체는 절댓값 힙 내에서만 사용되는 객체라 판단하였고, 해당 필드를 Main 클래스 내에서 사용하기 위하여 static 클래스로 선언하였습니다.

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.PriorityQueue;


public class Main {

    static class AbsNumber implements Comparable<AbsNumber>{
        private int num;

        public AbsNumber (int num) {
            this.num = num;
        }

        @Override
        public int compareTo(AbsNumber absNumber) {

            if (Math.abs(this.num) == Math.abs(absNumber.num)) {
                return Integer.compare(this.num, absNumber.num);
            }
            return Integer.compare(Math.abs(this.num), Math.abs(absNumber.num));
        }
    }

    public static void main(String[] args) throws IOException {

        PriorityQueue<AbsNumber> absHeap = new PriorityQueue<>();
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        int n = Integer.parseInt(reader.readLine());

        for (int i = 0; i < n; i++) {
            int x = Integer.parseInt(reader.readLine());

            if (x == 0) {
                if (absHeap.isEmpty()) {
                    System.out.println(0);
                } else {
                    System.out.println(absHeap.poll().num);
                }
            } else {
                absHeap.add(new AbsNumber(x));
            }
        }
    }

}
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.PriorityQueue;


public class Main11286_2 {

    static class AbsNumber {
        private int num;

        public AbsNumber (int num) {
            this.num = num;
        }
    }

    public static void main(String[] args) throws IOException {

        PriorityQueue<AbsNumber> absHeap = new PriorityQueue<>(
                (absNumber, t1) -> {

                    if (Math.abs(absNumber.num) == Math.abs(t1.num)) {
                        return absNumber.num - t1.num;
                    }
                    return Math.abs(absNumber.num) - Math.abs(t1.num);
                }
        );

        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        int n = Integer.parseInt(reader.readLine());

        for (int i = 0; i < n; i++) {
            int x = Integer.parseInt(reader.readLine());

            if (x == 0) {
                if (absHeap.isEmpty()) {
                    System.out.println(0);
                } else {
                    System.out.println(absHeap.poll().num);
                }
            } else {
                absHeap.add(new AbsNumber(x));
            }
        }
    }

}

두 해결방식의 차이점은 AbsNumber라는 객체를 객체 중심적으로 보고 해결하느냐, 혹은 해당 알고리즘에 특화된 객체라고 판단하고 해결하는가? 두 의도에 따라 달라진다고 생각합니다.

 

전자는. 내부 클래스에 선언했으므로 해당 AbsNumber를 이 과제에 특화되어 사용되는 클래스라고 판단하였기 때문에, Comparable 인터페이스를 구현하였습니다.

 

후자는, AbsNumber를 Comparable을 구현한 클래스로 만들면,  AbsNumber가 정렬 방식에 의존하게 된다는 생각이 들었습니다.  따라서, AbsNumber는 '절댓값의 수'라는 본질의 객체를 남겨두고,  과제를 해결하는 메소드 내에서 PriorityQueue에 사용할 정렬 방식을 Comparator<AbsNumber> 익명 내부 클래스 -> 람다로 전환하여 사용하였습니다.

 

감사합니다.!

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

이번 포스팅은 칼럼형 DBMS와 로그형 DBMS를 비교하는 글을 작성하고자 합니다.

 

데이터베이스의 필드는 행과 열의 교차점이며 특정 자료형의 단일 값 입니다.

http://wiki.hash.kr/index.php/%ED%95%84%EB%93%9C_%28%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4%29

'김모씨'는 첫 번째 행의 '이름'이라는 varchar(varchar2) 특정 자료형을 가진 값의 필드가 되는 것입니다. 데이터베이스는 디스크에 저장하는 방식에 따라, 칼럼형 DBMS와 로우형 DBMS로 나눌 수 있습니다.

https://dataonair.or.kr/db-tech-reference/d-lounge/expert-column/?mod=document&uid=52606

1. 로우형 데이터베이스

 

001 1001 KOSE 123-123
002 1002 GOSE 123-123

로우형 데이터베이스는 전통적인 DBMS 구조로 여러 필드의 값은 고유 식별 키로 구분할 수 있는 레코드 형식에 적합합니다. 특정 사용자의 요청이 여러 컬럼을 포함하는 데이터를 요구한다면 이러한 로우형 데이터베이스가 적합할 수 있습니다. 

 

로우형 데이터베이스에 적합한 상황은 무엇일까?

특정 인덱스로 학교 정보를 가져와야 한다고 생각해 보겠습니다.

select * from Schools where school_id = 10;

 이 경우, 인덱스 10인 학교 로우 데이터를 읽게 됩니다. 이때 발생하는 이점이 공간 지역성입니다. 공간 지역성이란 a[0], a[1] 처럼 같은 데이터 배열에 연속적으로 접근할 때 참조된 데이터 근처에 있는 데이터가 잠시 후 사용될 가능성이 높은 것입니다. 하나의 로우에 데이터를 저장하므로, 행 단위의 데이터 요청이 많은 상황이라면 로우형 데이터가 유리할 수 있습니다.

 

 

2. 칼럼형 데이터베이스

칼럼형 데이터베이스는 데이터를 로우 단위가 아니라 수직 분할하여 저장합니다. 로우를 연속해 저장하는 방식과 달리 같은 칼럼끼리 디스크에 연속해 저장하는 방식입니다. 장점은, 특정 컬럼만 따로 읽는 경우가 많은 경우, 해당 데이터를 로우 단위로 읽지 않으므로 칼럼별로 파일을 저장하거나 세그먼트 단위로 저장하면 효율성을 높일 수 있습니다.

 

효율성이 증가하는 이유는 무엇일까?

 

사번: 1000:001,003,2000:002,3000:004

 

  • 같은 칼럼의 여러 값을 한 번에 읽으면 캐시 활용도와 처리 효율성이 높아집니다. 최근 CPU는 벡터 연산을 통해 한 번의 CPU 명령으로 많은 데이터를 처리할 수 있습니다.
  • 자료형 별로 저장하면 압축률도 증가합니다. 각 컬럼마다 타입에 맞는 적절한 압축 알고리즘을 적용할 수 있습니다.
  • 한 컬럼의 데이터를 직렬화하여 저장하여 로우 방식과 다르게 데이터를 PK로 설정하여 데이터에 매칭되는 값을 포인터로 설정하여 데이터를 처리합니다.

 

 

3.  데이터 베이스 효율적인 저장 방식

로그를 남겨야 하는 시스템과 (WAL, Write ahead logging)과 다중 동시성 제어(Multiversion concurrency control)가 필요한 온라인 트랜잭션 처리 시스템(OLTP) 성 업무는 로우 기반의 시스템으로 구현하는 것이 적합합니다.

성능을 높이기 위해서는 파티셔닝과 인덱스, 캐싱을 활용해야 하고 별도의 온라인 분석 처리 데이터베이스 (OLAP)를 적용하는 방법을 강구해야 합니다.

 

[다중 동시성 제어 시스템이 로우형 데이터 베이스가 적합한 이유는 무엇일까?]

MVCC는 동시 접근을 허용하는 데이터베이스에서 동시성을 제어하기 위해 사용하는 방법 중 하나입니다.
MVCC 모델에서 데이터에 접근하는 사용자는 접근한 시점에서 데이터베이스의 SnapShot을 읽습니다. Snapshot에 대한 데이터 변경이 완료될 때까지 만들어진 변경사항은 다른 데이터 베이스 사용자가 볼 수 없습니다. 사용자가 데이터를 업데이트하면 이전의 데이터를 덮어 씌우는 것이 아니라 새로운 버전의 데이터를 UNDO 영역에 생성합니다. 이후, 이전 데이터와 비교하여 변경된 내용을 기록합니다. 사용자는 마지막 버전의 데이터를 읽게 됩니다.

로우형 데이터베이스는 하나의 행에 대한 데이터를 연속된 시퀀스로 기록하기 때문에 다중 동시성 제어 시스템에서는 특정 값이 바뀔 경우 해당 로우 전체를 최신 버전의 데이터를 UNDO 영역에 생성하는 것입니다. 따라서, 컬럼형에 비해 속도가 빠를 수 있습니다.

 

<추가>
PostGreSqL은 주기적으로 VACUUM 하여 기존 데이터에 대한 처리를 진행
Oracle은 Rollback segment 방식을 활용하여 업데이트된 데이터를 새롭게 변경하고
이전 데이터는 Rollback segment에 보관하는 방식을 따릅니다. 

https://scorpio-mercury.tistory.com/32

 

참조: https://dataonair.or.kr/db-tech-reference/d-lounge/expert-column/?mod=document&uid=52606

안녕하세요. 회사와 함께 성장하고 싶은 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를 처리하는 방법을 정리하였습니다.

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

 

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

+ Recent posts