한가로운 어느 일요일, 저는 평소와 같이 열심히 테스트 코드를 작성하고 있었습니다. 테스트 코드를 작성하던 중, 언젠가 테스트 코드에서는 의존성 주입이 안된다는 사실을 알고 있냐는 크루의 물음이 떠올랐습니다. 그동안 편의를 위해 Autowired를 마구 쓰던 저는 이번 기회에 한번 테스트해보자는 마인드로 생성자 주입을 하도록 코드를 변경해 보았습니다.

그 결과…

@SpringBootTest
public class ProductServiceTest {

    private final ProductService productService;

    public ProductServiceTest(ProductService productService) {
        this.productService = productService;
    }

...

으악

 

역시 안되는군요.. 크루는 거짓말을 하지 않았습니다. 그렇다면 왜 되지 않는 걸까요? @SpringBootTest를 써 주었으니 문제가 없는게 아닐까요? 대충 에러 메세지를 보아 하니.. ProductService라는 파라메터에 대한 ParameterResolver가 등록되지 않았다고 뜨는군요. 이 부분을 한번 구글의 힘을 빌려 찾아 보겠습니다.

해결법 자체는 생성자에 있는 파라메터를 제거하고 @BeforeEach에서 초기화를 해주거나 @Autowired로 필드를 초기화한다고 나오는 거 같습니다. 해결법은 알겠고.. 원인은 무엇일까요?

 

무엇이 문제였을까?


@SpringBootTest를 붙이게 되면 Junit Jupiter에 SpringExtension이 자동으로 등록되어 사용됩니다. 조금 소스를 까보면서 해결책이 있을지 찾아보겠습니다.

SpringExtension.java 코드를 뒤적이다 보니 문구를 발견했습니다.

* <p><strong>WARNING</strong>: If a test class {@code Constructor} is annotated
	 * with {@code @Autowired} or automatically autowirable (see {@link TestConstructor}),
	 * Spring will assume the responsibility for resolving all parameters in the
	 * constructor. Consequently, no other registered {@link ParameterResolver}
	 * will be able to resolve parameters.

읽어보니 @Autowired가 달린 Constructor나 TestConstruct 어노테이션을 사용한 경우에 대해서만 생성자 내의 모든 파라메터를 처리한다고 하네요. 추측하건데 프로덕션 코드에서는 Autowired 어노테이션을 생략 가능하지만, SpringExtension에서는 Autowired 어노테이션이 생략 불가능해서 위와 같은 에러가 떴던 거 같습니다.

 

제 생각으로는 아마, 테스트 클래스를 관리하는 주체가 Spring이 아닌 Junit5이기 때문에 Junit의 방식을 따라야 하는데, Junit에서는 빈을 주입하기 위해서는 Junit에서 제공하는 ParameterResolver를 통해 주입해야 하기 때문인 것 같습니다. Junit의 파라메터 중 어떤 파라메터를 스프링의 ApplicationContext에서 주입받아야 하는지 구분하기 위해 Autowired 어노테이션에 의존하게 된 것 같습니다.

https://www.baeldung.com/junit-5-parameters

 

결론

결론적으로, Autowired는 필수적으로 써주어야 합니다. 주석 내에 @TestConstruct라는 어노테이션이 있어 이를 사용해 보겠습니다. @TestConstruct는 생성자 주입을 어떻게 처리할 지 결정한다고 보는게 가장 좋을 것 같습니다. ALL 옵션인 경우, 모든 생성자에 Autowired가 달려있는 것으로 간주하며 생성자 주입을 시켜 줍니다. ANNOTATED 옵션인 경우 관련 어노테이션(@Autowired, @Value, …)이 붙어있는 경우에만 생성자 주입이 동작합니다.

@SpringBootTest
@TestConstructor(autowireMode = AutowireMode.ALL)
public class ProductServiceTest {

    private final ProductService productService;

    public ProductServiceTest(ProductService productService) {
        this.productService = productService;
    }

 

워크스페이스 생성~슬랙 봇 생성


1. Slack에 MOMO라는 워크스페이스를 따로 만들어 두었습니다.

2. https://api.slack.com/apps 에 접속합니다.

3. OAuth & Permissions에서 봇에게 권한을 부여합니다.

4. 상단의 Install to Workspace 버튼을 누르면 슬랙 봇이 추가됩니다.

5. 이후 다시 OAuth & Permissions로 가면 봇의 OAuth Token이 발급됩니다.

 

 

슬랙 메세지 전송

  • 슬랙 봇도 만들고 OAuth Token도 발급받았으니 본격적으로 슬랙 메세지를 전송해 봅시다.
  • 슬랙 메세지 전송 API는 다음과 같습니다.

 

채널 ID는 채널 정보의 하단에 존재한다.

 

  • 언뜻 살펴보기에.. Authorization 헤더에 위에서 받아온 토큰을 입력해 주고, channel에 채널ID 라는걸 넣어주면 잘 동작할 것 같습니다. 그렇다면 ChannelID는 뭘까요? 맨 아래에 채널 ID라는게 따로 존재하는 모습을 볼 수 있습니다. 이제 전달해주기 위한 모든 데이터들을 다 얻었으니 본격적으로 슬랙에 메시지를 던져보기 위한 코드를 작성해 보겠습니다. 외부 API를 호출하므로 RestTemplate를 통해 Api를 사용하겠습니다.
  • https://www.baeldung.com/rest-template
  public void index() throws JsonProcessingException {
      final var token = "슬랙봇_토큰";
      final var url = "https://slack.com/api/chat.postMessage";
      final var channelID = "채널아이디";

      HttpHeaders httpHeaders = new HttpHeaders();
      httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
      httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer " + token);

      SlackMessage slackMessage = new SlackMessage(channelID, "hello world!");
      ObjectMapper objectMapper = new ObjectMapper();
      String jsonData = objectMapper.writeValueAsString(slackMessage);
      HttpEntity<String> request = new HttpEntity<>(jsonData, httpHeaders);

      String response = restTemplate.postForObject(url, request, String.class);
      System.out.println(response);
  }

 

사실 위 방식을 그대로 잘 따라했다면 에러가 뜹니다..ㅎ 코드를 짜고 요청을 계속해서 날려보아도 봇이 채널에 존재하지 않는다는 에러가 발생하였습니다. 구글링 결과 봇 권한을 더 주어야 한다고 슬랙 봇 권한을 보니 채널에 참여하는것도 권한을 줘야 한다고 합니다. 따로 권한을 부여하여 주고 채널을 새롭게 파서 채널에 봇을 입장시켜 줍시다~

  /invite @봇_이름

 

이후, 다시 로직을 실행해 보니 정상적으로 메시지가 가는 모습을 볼 수 있습니다ㅎㅎ

 

LogBack이란?

Java의 log4j의 후속버전입니다. log4j의 아키텍처 기반으로 재작성 되었으며, slf4j을 지원하기 때문에 다른 logger로 얼마든지 바꿀수 있게 구현되어 있습니다. 

 

로그 레벨

사전 지식으로 로그 레벨에 대해서 알아야 합니다.

  1. ERROR : 요청을 처리하는 중 오류가 발생한 경우 표시하는 로그입니다.
  2. WARN : 처리 가능한 문제, 향후 시스템 에러의 원인이 될 수 있는 경고성 메시지를 나타냅니다.
  3. INFO : 상태변경과 같은 정보성 로그를 나타냅니다.
  4. DEBUG : 프로그램을 디버깅하기 위한 정보를 표시합니다.
  5. TRACE : 추적 레벨을 Debug보다 훨씬 상세한 정보를 나타냅니다.

로그 레벨의 경우는 역순으로, ERROR이 가장 높고, TRACE가 가장 낮은 레벨입니다.

 

 

Logging With Springboot

  • spring-boot-starter-web 의존성 추가
  • application.yml 또는 logback-spring.xml에서 설정하는것이 포인트입니다.
  • 설정할 부분은 대략 다음과 같습니다.
    • 콘솔, 파일, DB 등 로그를 출력하는 방법을 지정하는 Appender
    • 출력할 곳을 정하는 logger
    • logback 설정 파일 경로는 /resources/logback-spring.xml입니다.

Configuration

  1. Appender에서 콘솔에 출력되는 형식을 지정합니다.
    • Pattern에서 지정한 방식대로 시간/레벨 등이 설정된 후 콘솔에 메시지를 저장합니다.
    • filter, encoder, policy 등을 지정할 수 있습니다.프로젝트에서는 로그가 특정 크기(10MB)가 되면, 새로운 로그를 저장하도록 구현할 계획이므로 RollingFileAppender을 사용합니다.
    • RollingFileAppender엔 등록되어 있는 몇가지 정책(RollingPolicy)들이 있습니다. 이 중 파일 크기 기반으로 정책을 설정할 계획이므로 SizeBasedTriggeringPolicy를 사용하였습니다. 또한, 저장되는 로그 파일을 10개씩 관리하기 위해 FixedWindowRollingPolicy를 같이 사용하였습니다.
    • https://logback.qos.ch/manual/appenders.html

filter

  • DENY, ACCEPT, NEUTRAL 값을 설정 가능합니다.
  • DENY : 로그 이벤트가 즉시 삭제됩니다.
  • NEUTRAL : 다음 필터로 넘어갑니다.
  • ACCEPT : 나머지 필터의 호출을 건너뛰고 로깅 이벤트가 즉각 처리됩니다.

 

LevelFilter

  • 레벨에 따라서 필터를 처리합니다.
  • onMatch나 onMisMatch 속성을 사용하여 지정한 레벨과 같을 때, 이벤트를 accept 하거나 deny 할 수 있습니다.
  • 다른 Filter에 대한 정보는 다음 사이트를 참고하시면 됩니다.
  • https://logback.qos.ch/manual/filters.html

 

적용기

자.. 준비는 대충 된 거 같으니,, 프로젝트에 직접 적용해 봅시다.

resources/logback-spring.xml

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <Pattern>%d{HH:mm} %-5level %logger{36} - %msg%n</Pattern>
        </layout>
    </appender>

    <appender name="MOMO_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>

        <file>MOMO_LOG.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
            <fileNamePattern>MOMO-%i.log</fileNamePattern>
            <minIndex>1</minIndex>
            <maxIndex>10</maxIndex>
        </rollingPolicy>
        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
            <maxFileSize>10MB</maxFileSize>
        </triggeringPolicy>
    </appender>

    <root level="INFO">
        <appender-ref ref="MOMO_LOG"/>
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>
  • 10MB 파일크기 단위로 10개의 로그 파일을 저장하도록 구현하였습니다.
    • SizeBasedTriggeringPolicy, FixedWindowRollingPolicy를 사용합니다.
  • 표준 출력에 대해서는 ConsoleAppender로 바로 출력하도록 합니다. 이 부분을 생략하면 스프링 부트 실행이 콘솔에 찍히지 않습니다.

 

Filter

 <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>ERROR</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
</filter>

레벨에 따라서 적용되는 필터입니다. 이번 예시에서는 ERROR 레벨에서만 로그를 찍도록 하기 위해 ERROR 단계에서의 onMatch 속성을 ACCEPT로 설정하고, ERROR 레벨이 아니라면 로그를 찍지 않도록 하기 위해 ERROR 레벨이 아니라면 무시하도록 DENY를 걸어주었습니다. 참고로 값들에 대한 설명은 다음과 같습니다.

https://logback.qos.ch/manual/filters.html

  1. ACCEPT : 로깅 이벤트를 바로 실행합니다.
  2. DENY : 나머지 필터를 참조하지 않고 로깅 이벤트를 바로 종료
  3. NEUTRAL : 다음 필터로 넘어갑니다.

Encoder

<encoder>
    <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>

pattern에 명시한 형식대로 로그가 출력됩니다. Pattern에 명시할 수 있는 데이터는 다음 자료를 참고하시면 좋을 것 같습니다.

https://logback.qos.ch/manual/layouts.html

Policy

<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
    <fileNamePattern>MOMO-%i.log</fileNamePattern>
    <minIndex>1</minIndex>
    <maxIndex>10</maxIndex>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
    <maxFileSize>10MB</maxFileSize>
</triggeringPolicy>

 

이번 예제에서는 rollingPolicy와 triggeringPolicy를 사용하였습니다.  triggeringPolicy는 rollover가 발생하는 시점이며, rollingPolicy는 rollover 발생 시 처리 방식에 대해 명시하였습니다. TriggeringPolicy인 SizeBasedTriggeringPolicy로 인해10MB의 파일 크기가 넘어간다면 FixedWindowRollingPolicy가 발생하여 <file>에 적힌 파일명에 숫자를 붙여서 다른 파일로 보관하며, 10개의 파일을 보관합니다.

 

테스트

적용을 다 했으면, 이제 직접 테스트해 봅시다. 예외를 터트리면 ERROR 단계일거 같으니, 한번 아무렇게나 예외를 터트려 봅시다.

public Group findGroup(Long id) {
    throw new IllegalArgumentException("hello world!");
    //return groupSearchRepository.findById(id)
    //        .orElseThrow(() -> new GroupException(NOT_EXIST));
}

실행했는데 로그 파일은 생성되지만, 슬프게도 아무 로그도 찍히지 않는 문제가 발생했습니다. 문제가 무엇일까요? 답은 예외를 터트린다고 하더라도 로그 레벨이 ERROR이 아니라는 점입니다.

즉, 예외를 처리하는 ControllerAdvice에서 Logger.error 메서드를 호출하여서 명시적으로 ERROR 로그 레벨의 로그를 출력해 주면 됩니다. 이때 Exception 객체를 넘겨주면, StackTrace도 함께 출력됩니다.

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ExceptionResponse> handleException(IllegalArgumentException e) {
    logger.error("error", e);

    return ResponseEntity.badRequest().build();
}

 

테스트 해보다가 참고로 알게된 점은.. 서비스 테스트에서 예외를 터트리더라도 Advice로는 가지 않는 문제가 있었습니다. ControllerAdvice는 컨트롤러 단에서 터지는 예외에 대해서만 처리를 하기 때문에 서비스 테스트에서 백날 예외를 터트려 봐야 Advice로 들어가지 않는다고 하더라구요.. 이거때문에 삽질을 좀 오랜시간 했습니다..ㅠ

AOP


  • Aspect-Oriented Programing, 객체 지향 프로그래밍을 보완합니다.
  • OOP에서 모듈화의 핵심 단위는 클래스, AOP에서 모듈화의 단위는 “측면”
  • 여러 유형과 객체를 가로지르는 문제의 모듈화를 가능하게 해줍니다.

AOP 용어


  • 애스펙트(Aspect)
    • AOP의 기본 모듈, 핵심 모듈
    • 한 개 이상의 포인트컷과 어드바이스를 갖고 있습니다.
    • Advisor가 Aspect의 역할을 합니다.
  • 조인 포인트(Join point)
    • Advice를 적용할 위치를 지정합니다.
  • 어드바이스(Advice)
    • 부가기능을 담고 있는 모듈
    • 서비스를 Transaction 서비스, 비즈니스 서비스로 나눈다고 하면 이 중 Transaction 서비스를 부가기능이라고 생각하면 됩니다.
      • 우리가 원한건 비즈니스 서비스고, 부가적인 처리가 필요하다는 관점
      • 주로 인프라 관련 처리들이 부가기능
    • 스프링은 어드바이스를 인터셉터로 모델링하고 조인 포인트 주변에 인터셉터 체인 유지
  • 포인트 컷(Pointcut)
    • 어드바이스를 적용할 조인 포인트를 선별하는 모듈
  • 타겟(Target)
    • 부가기능(Advice)을 적용할 대상
  • 프록시(Proxy)
    • 클라이언트와 타겟 사이에서 부가기능을 제공하는 객체
    • 스프링에서 프록시는 JDK 동적 프록시 또는 CGLIB 프록시로 구현
    • 스프링에서의 프록시를 제공하는 두가지 방법 : 리플렉션(JDK), CGLIB 라이브러리 사용
      • 스프링은 JDK가 기본, 스프링 부트에서 CGLIB 라이브러리 사용
      • JDK 프록시 → 메서드 단위, 인터페이스여야만 사용 가능

 

 

동적 프록시(Dynamic Proxy)


공식 문서의 설명을 따다 가져오면 다음과 같습니다.

동적 프록시 클래스 는 클래스 인스턴스 의 인터페이스 중 하나를 통한 메서드 호출이 균일한 인터페이스를 통해 인코딩되고 다른 객체에 전달되도록 런타임에 지정된 인터페이스 목록을 구현하는 클래스입니다.

 

말이 너무 어렵습니다.. 구현된 코드를 보면서 하나씩 알아보면 좋을 것 같습니다ㅎㅎ 구현된 코드의 요구사항은 다음과 같습니다.

Transactional 어노테이션이 붙어있는 메서드가 호출된다면, 해당 메서드가 온전히 실행되면 commit, 예외가 발생하면 rollback 한다.
public class TransactionHandler implements InvocationHandler {

    private final PlatformTransactionManager transactionManager;
    private final AppUserService appUserService;

    public TransactionHandler(PlatformTransactionManager transactionManager, AppUserService appUserService) {
        this.transactionManager = transactionManager;
        this.appUserService = appUserService;
    }

    @Override
    public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
        Method realMethod = Arrays.stream(appUserService.getClass().getDeclaredMethods())
                .filter(m -> compareTwoMethodSame(m, method))
                .findAny()
                .orElseThrow();

        if (realMethod.isAnnotationPresent(Transactional.class)) {
            final var transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());
            try {
                Object methodReturnValue = method.invoke(appUserService, args);
                transactionManager.commit(transactionStatus);
                return methodReturnValue;
            } catch (Exception e1) {
                transactionManager.rollback(transactionStatus);
                throw new DataAccessException();
            }
        } else {
            try {
                return method.invoke(appUserService, args);
            } catch (Exception e1) {
                throw new DataAccessException();
            }
        }
    }

    private boolean compareTwoMethodSame(Method a, Method b) {
        if (!a.getName().equals(b.getName())) {
            return false;
        }
        Class<?>[] aParams = a.getParameterTypes();
        Class<?>[] bParams = b.getParameterTypes();
        if (aParams.length != bParams.length) {
            return false;
        }

        for (int i = 0; i < aParams.length; i++) {
            if (!aParams[i].getTypeName().equals(bParams[i].getTypeName())){
                return false;
            }
        }

        return true;
    }
}

위 코드에서 중점이 되는 부분은 invoke 메서드입니다. 메소드가 호출되면, invoke()가 호출되며 전달받은 Method 객체를 사용하여 메서드를 호출하기 전, 후로 실행 로직을 처리할 수 있습니다. 실제 메소드가 호출되는 부분문 method.invoke() 입니다.

 

그렇다면 사용 측면에선 어떻게 사용하면 될까요? java.lang.reflect.Proxy 클래스의 newProxyInstance 메소드를 사용하여 프록시 객체를 만들어 줄 수 있습니다.

final var appUserService = new AppUserService(userDao, userHistoryDao);
final InvocationHandler invocationHandler = new TransactionHandler(platformTransactionManager, appUserService);
final UserService userService = (UserService) Proxy.newProxyInstance(
        UserService.class.getClassLoader(),
        new Class[]{UserService.class},
        invocationHandler
);

주의할 점은, UserService는 인터페이스 타입입니다. Proxy의 newProxyInstance 메서드의 두번째 인자로 반드시 인터페이스 타입을 명시해 주어야 합니다. 자세한 사용 설명은 다음 사이트를 참고하시면 좋을 것 같습니다.

https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html

 

 

ProxyFactoryBean


스프링의 빈을 기반으로 AOP 프록시를 만들어 주는 객체입니다. 3개의 역할이 필요합니다.

  1. Advice
    • 부가기능을 담고 있는 클래스입니다.
  2. Pointcut
    • Advice가 적용될 조인 포인트를 선별합니다.
    • 조인 포인트는 Advice가 적용 될 위치라고 생각하시면 됩니다.
  3. Advisor
    • Pointcut과 Advice를 가지고 있는 객체입니다.
    • AOP의 Aspect와 같습니다.

Advice

public class TransactionAdvice  implements MethodInterceptor {

    private final PlatformTransactionManager transactionManager;

    public TransactionAdvice(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public Object invoke(final MethodInvocation invocation) throws Throwable {
        TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            Object methodReturnValue = invocation.proceed();
            transactionManager.commit(transaction);
            return methodReturnValue;
        } catch (Exception e) {
            transactionManager.rollback(transaction);
            throw new DataAccessException();
        }
    }
}

Pointcut

public class TransactionPointcut extends StaticMethodMatcherPointcut {

    @Override
    public boolean matches(final Method method, final Class<?> targetClass) {
        return method.isAnnotationPresent(Transactional.class);
    }
}

Advisor

public class TransactionAdvisor implements PointcutAdvisor {

    private final TransactionAdvice advice;
    private final TransactionPointcut pointcut;

    public TransactionAdvisor(TransactionAdvice advice, TransactionPointcut pointcut) {
        this.pointcut = pointcut;
        this.advice = advice;
    }

    @Override
    public Pointcut getPointcut() {
        return pointcut;
    }

    @Override
    public Advice getAdvice() {
        return advice;
    }

    @Override
    public boolean isPerInstance() {
        return true;
    }
}

각각에 맞추어서 구현이 완료되었다면, 이제 ProxyBeanFactory 객체를 생성하여 설정해 주고 인스턴스를 생성하면 됩니다.

  1. 우선 프록시를 적용할 인스턴스와 Advisor을 미리 만들어 둡니다.
  2. ProxyBeanFactory에 인스턴스와 Advisor을 넣어주고, getObject() 메서드로 프록시 객체를 얻어올 수 있습니다.
final ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
final UserService temporaryUserService = new UserService(userDao, userHistoryDao);
TransactionAdvice advice = new TransactionAdvice(platformTransactionManager);
final TransactionAdvisor advisor = new TransactionAdvisor(
        advice,
        new TransactionPointcut()
);

proxyFactoryBean.setTarget(temporaryUserService);
proxyFactoryBean.addAdvisor(advisor);
final UserService userService = (UserService) proxyFactoryBean.getObject();

 

 

DefaultAdvisorAutoProxyCreator


위 코드를 보면, 프록시 객체를 만들기 위해 많은 노력이 드는 것을 볼 수 있습니다. ProxyBeanFactory도 생성해야 하고.. Advice도 매번 만들어주어야 하고.. 인스턴스도 만들어주어야 하고..

이러한 부분들을 DefaultAdvisorAutoProxyCreator을 빈 등록만 해두면 쉽게 사용할 수 있습니다.

@Configuration
public class AopConfig {

    @Bean
    public TransactionAdvice transactionAdvice(PlatformTransactionManager platformTransactionManager) {
        return new TransactionAdvice(platformTransactionManager);
    }

    @Bean
    public TransactionPointcut transactionPointcut() {
        return new TransactionPointcut();
    }

    @Bean
    public TransactionAdvisor transactionAdvisor(TransactionAdvice advice, TransactionPointcut pointcut) {
        return new TransactionAdvisor(advice, pointcut);
    }

    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        return new DefaultAdvisorAutoProxyCreator();
    }
}

사실 이 부분은 이해가 잘 되지 않았습니다. 위의 ProxyFactoryBean은 명시적으로 프록시 객체를 만들어 주었거든요. 이 부분에서는 프록시 빈을 만드는 느낌은 아니었습니다. 

이 부분은 찾아보니 빈 후처리기에서 동작하며 Advisors를 기반으로 AOP 프록시 객체를 만들어 준다고 합니다. 자세한 내용은 다음과 같습니다. 

 

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/aop/framework/autoproxy/DefaultAdvisorAutoProxyCreator.html

 

DefaultAdvisorAutoProxyCreator (Spring Framework 5.3.23 API)

BeanPostProcessor implementation that creates AOP proxies based on all candidate Advisors in the current BeanFactory. This class is completely generic; it contains no special code to handle any particular aspects, such as pooling aspects. It's possible to

docs.spring.io

 

상황


  • DELETE API를 호출하여 게시글을 삭제하려고 하는데 계속해서 오류가 발생함..
  • 문제점의 원인을 찾아보니 하나의 db 세션이 사용중인 경우에는 사용할수 없다고 함.. 그런데 애초에 여러개의 db 세션을 만든 적이 없는데..?? 해서 찾아봄

문제 원인


  • Market.py와 Models.py에서 서로 import해주는 이유로 생기는 문제로 확인
    • Market.py에 app와 db가 있고 url 라우팅 존재
    • Models.py에 db.model을 상속받는 모델들 존재
  • 따라서, 따로 config.py를 두어서 config에 db와 app를 두고 Models.py와 Market.py가 config에 있는 db와 app를 사용하도록 하면 서로 import해주지 않으므로 문제 해결

Config.py

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///Market.sqlite3'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
db = SQLAlchemy(app)

결론

  • 순환 구조로 import하는걸 지양하자

'프레임워크 > Flask' 카테고리의 다른 글

[ Flask ] 파일 불러오기  (0) 2021.12.24

유저가 업로드한 프로필 이미지를 사용해야 하는데, 업로드된 이미지을 불러오는 기능을 어떻게 구현해야 할 까 해서 찾아보게 된 방식임. 대부분 정적 파일을 관리하는 또다른 서버를 둔다고 하지만 개발 단계에서의 이미지 업로드 / 이미지 표시를 해보아야 하므로 이미지 표시 기능을 따로 구현

 

1. 업로드 된 파일을 가져오는 파트


- Flask의 send_from_directory 메서드 사용

- 첫번째 인자는 폴더, 두번째 인자는 파일 이름

- 해당 리소스를 전송시켜준다.

 

@app.route('/uploads/<filename>')
def uploaded_file(filename):
    return send_from_directory('uploaded', filename)

 

2. HTML에서 이미지를 불러오는 파트


- url_for 메서드를 사용하면 첫번째 인자인 함수 이름을 가진 url을 리턴받을 수 있음.

- 두번째 인자부터는 키워드 인자로 변수 규칙에 맞추어서 대입된 url을 받아올 수 있음.

- 따라서, 아래 코드에서의 url_for의 결괏값은 uploads/eagle.jpg이다.

<image class="itemimage" src="{{ url_for('uploaded_file', filename='eagle.jpg') }}"></image>

 

'프레임워크 > Flask' 카테고리의 다른 글

[ Error ] [ SQLAlchemy ] is already attached to session  (0) 2021.12.29

Import


from django.db.models import Q, F

Q object


  • Django DB 관련 작업에서 사용할 수 있는 SQL 조건문

  • filter 메서드에서 주로 사용

  • OR이나 AND 연산자나 NOT 연산자를 사용하여 조건을 정의하기 위해 사용된다.

      Question.objects.filter(Q(subject__icontains=query) or Q(content__icontains=query))
    • 각 조건을 Q object로 만들고, 두개의 Q object를 각각 or 연산
    • or( | ), and( & ), not( ~ )

F Object

  • 쿼리 내에서 모델 필드 접근할 때 사용
  • 모델 필드나 annotated column의 값을 가져올 때 사용된다.
from django.db.models import F
Entry.objects.filter(number_of_comments__gt=F('number_of_pingbacks'))
  • 표현식과 함께 사용할 때는 반드시 유의해야 한다.
    • F 함수를 만나게 되면 캡슐화된 SQL문을 생성하기 위해 파이썬 연산자를 오버라이딩 한다.
# Tintin filed a news story!
reporter = Reporters.objects.get(name='Tintin')
reporter.stories_filed += 1
reporter.save()

###############################################

from django.db.models import F

reporter = Reporters.objects.get(name='Tintin')
reporter.stories_filed = F('stories_filed') + 1
reporter.save()

# reporter.refresh_from_db(fields=['stories_filed'])

reporter.name = 'Tintin Jr.'
reporter.save() # 
  • 위 소스를 아래 소스로 바꾸려고 하는건데.. 문제가 있다.
  • 아래 소스는 F('stories_filed') + 1 자체를 SQL Syntax로 만들어버려서 reporter.stories_filed의 값이 1 증가하는 쿼리가 실행된다. 이 때, reporter.stories_filed의 값을 갱신하는것이 아님. 데이터베이스 자체의 SQL문 실행
  • reporter.stories_filed 값을 실제 저장된 값으로 업데이트하기 위해선 모델을 데이터베이스에서 다시 불러와주어야 한다.
  • F Object는 save() 이후에도 모델에 남아있는 문제가 있으며, 각 save()에 적용된다. 따라서 초기 값이 1이라고 할 때, 처음 save 연산자를 해주면 SQL문이 실행되어 2가 되는데, 다음에 또 save 연산을 해주면 또 SQL문이 실행되어 3이 된다.이 과정을 해결해주기 위해서는 refresh_from_db()를 사용해주면 된다.
    • Model.refresh_from_db(using=None, fields=None)

pk

  • get을 할때, pk로 입력하나 id로 입력하나 같은 결과가 나온다.
  • pk가 id__exact와 같다고 한다.
model.objects.get(id=1)
model.objects.get(pk=1)

'프레임워크 > Django' 카테고리의 다른 글

[ Django ] Session  (0) 2021.10.17
[ Django ] CSRFToken  (0) 2021.10.16
[ Django ] JWT(djangorestframework-simplejwt)  (0) 2021.10.15
[ Django ] JWT  (0) 2021.10.14
[ Django ] Choice  (0) 2021.09.15

전반적인 세션 개념

  • HTTP에서는 새로운 페이지를 요청할 때마다 새로운 연결하여 상태유지가 되지 않음.
  • 사용자의 상태를 유지하기 위해 쿠키와 세션 사용
    • 브라우저마다 상태 저장
  • 세션의 데이터(사용자의 데이터)는 서버에 저장된다.
  • 서버에서는 세션에 데이터를 저장하고, 세션ID를 쿠키로 클라이언트에 발급한다. (Set-cookie)
  • 클라이언트는 발급받는 세션ID를 쿠키에 저장한다.
  • https://developer.mozilla.org/ko/docs/Learn/Server-side/Django/Sessions

세션의 장점

  • 서버에 사용자의 데이터를 저장하므로 보안상 우수

세션의 단점

  • 서버에 사용자의 데이터를 저장하므로 서버의 부하 증가

Django에서의 Session


  • 세션에 데이터를 저장하기 전까지는 Sessionid를 따로 클라이언트에 전송하지 않음.
  • 세션에 데이터를 저장하는 순간부터 Sessionid를 클라이언트에 전달
  • Django에서의 세션 사용법
def f(request):
    ...
    request.session['저장할 데이터명'] = 값
  • request.session : 현재 사용자/브라우저에 대한 세션, 딕셔너리처럼 사용 가능
  • 세션에 데이터를 간접적으로 저장한 경우 따로 Django에 modified를 설정하여 데이터를 저장했음을 알려주어야 함.
    • 예를들면 session에 리스트가 저장되어 있고, 리스트의 요소중 하나의 값을 바꿧다던지..
request.session.modified = True

Session 관련 Setting 몇가지(Settings.py)


  1. SESSION_COOKIE_AGE
    • 세션 쿠키 지속기간(초), 기본값은 2주
  2. SESSION_COOKIE_HTTPONLY
    • 세션 쿠키에 HttpOnly 설정, 기본값은 True
    • 세션 쿠키를 JavaScript에서 읽는건 문제가 될 수 있으므로 False를 굳이??
  3. SESSION_COOKIE_NAME
    • 세션 쿠키의 이름, 기본값은 sessionid
  4. SESSION_COOKIE_PATH
    • 세션 쿠키의 경로, 기본값은 /
  5. SESSION_COOKIE_SECURE
    • SECURE 옵션 사용여부, https를 사용한다면 True로 하는게 좋음.
    • 기본값은 False
  6. SESSION_SAVE_EVERY_REQUEST
    • 모든 request의 session 데이터 저장
    • 원래는 session 데이터가 변경된 경우에만 저장
    • session이 텅 빈 경우는 True라 하더라도 생성되지 않음.

'프레임워크 > Django' 카테고리의 다른 글

[ Django ] Q, F object  (0) 2021.10.20
[ Django ] CSRFToken  (0) 2021.10.16
[ Django ] JWT(djangorestframework-simplejwt)  (0) 2021.10.15
[ Django ] JWT  (0) 2021.10.14
[ Django ] Choice  (0) 2021.09.15
  • CSRF 공격을 막기 위한 방법
  • CSRF 토큰이란 다른 사이트에서 접근할 수 없는 특정 사이트에 고유한 비밀값이라고 보면 된다.
  • Django에서는 CsrfViewMiddleware에 의해 설정된다.
  • 원래는 form태그에서 {% csrf_token %} 태그를 설정
{% csrf_token %}
  • 해당 태그가 존재하는 페이지에 접속하게 되면 csrf_token이 쿠키에 추가되는것을 볼 수 있다.
  • CsrfViewMiddleware에서는 먼저, POST에 있는 csrfmiddlewaretoken을 찾고, 없다면 X-csrftoken을 찾는다.
  • X-CSRFToken의 경우는 Ajax 등 api에서 사용된다.

과정


  1. response에 set_cookie를 설정하여 csrf_token 쿠키를 넣어서 전달한다.
  2. request에서는 전달받은 csrf_token을 POST의 csrfmiddlewaretoken에 넣어서 전달하거나, 아니면 헤더에 추가한다. ⇒ X-CSRFToken에 넣어서 전달
  3. 서버에서는 쿠키에 존재하는 csrf_token의 값하고 csrfmiddlewaretoken이나 X-CSRFToken의 값을 비교하는 방식으로 진행한다.

관련 데코레이터


  • ensure_csrf_cookie 데코레이터 추가
    • csrf_token set-cookie 추가
@method_decorator(ensure_csrf_cookie)
def post(self, request, *args, **kwargs):
  • csrf_protect 데코레이터 추가
    • csrf_token 인증 진행
@method_decorator(csrf_protect)
def post(self, request, format=None):

'프레임워크 > Django' 카테고리의 다른 글

[ Django ] Q, F object  (0) 2021.10.20
[ Django ] Session  (0) 2021.10.17
[ Django ] JWT(djangorestframework-simplejwt)  (0) 2021.10.15
[ Django ] JWT  (0) 2021.10.14
[ Django ] Choice  (0) 2021.09.15
  • 이전에는 DjangoRestFramework-jwt를 사용하였으나, 찾아보니 DjangoRestFramework-jwt가 더이상 업데이트 되지 않으며, simplejwt 사용이 권장되고 있다고 함.

설치


# 구버전 ㅂㅇ..
pip uninstall djangorestframework-jwt

# 신버전 ㅎㅇ~ 
pip install djangorestframework-simplejwt

Settings.py 설정


  • INSTALLED_APPS에 추가
INSTALLED_APPS = [
    ...
    'rest_framework_simplejwt',
    ...
]
  • 인증방식에 JWTAuthentication 추가
REST_FRAMEWORK = {
    ...
    'DEFAULT_AUTHENTICATION_CLASSES': (
        ...
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
    ...
}

urls.py 설정


  • TokenObtainPairView

    • POST 방식의 API
    • username, password를 넘겨주면 만약 사용자 인증이 된다면 토큰 발급
    • 기존 djangorestframework_jwt와 다르게 refresh, access 토큰이 따로 발급된다.
  • TokenRefreshView

    • POST 방식의 API
    • refresh 토큰을 넘겨주면 새로운 토큰을 발급받음.(access 토큰)
  • 여기서, 실제로 우리가 사용하게 될 토큰은 access 토큰이라고 보면 된다.

from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    ...
        path('apitokenauth/', TokenObtainPairView.as_view()),
    path('apitokenrefresh/', TokenRefreshView.as_view()),
    ...
]

쿠키 설정


  • 사용자 브라우저로 하여금 쿠키를 설정하게 하기 위해선 Set-cookie 헤더를 넘겨주어야 한다.
  • response의 set_cookie 메서드를 사용하여 쿠키를 설정해 준다.
  • 예제에서는 쿠키의 키를 'JWT'로 설정하였으며 httponly를 설정하여 Script에서는 읽지 못하도록 하였음.
class TokenObtainPairView_ud(TokenObtainPairView):
    def post(self, request, *args, **kwargs):
        response = super().post(request, *args, **kwargs)
        if response.status_code != 200:
            return response

        response.set_cookie(
            key = 'JWT',
            value = response.data['access'],
            max_age = 300,
            httponly = True,
            samesite = 'Lax'
        )
        return response

# urls.py는 아래로 당연히 바꾸어주어야 함.
path('apitokenauth/', TokenObtainPairView_ud.as_view()),

request → 쿠키


  • 서버에서 set_cookie 헤더가 포함된 response를 전달하면, 클라이언트에서는 키값이 'JWT'인 쿠키가 생성되어 있을 것이다.
  • 클라이언트에서 서버로 다음에 쿠키가 포함된 request를 보낼 때, 서버에서는 쿠키를 읽어서 인증하는 과정이 필요하다.
  • 따라서, 쿠키에서 JWT 토큰을 가져와서 읽고, 토큰을 검증하면 된다.
    • JWTAuthentication을 커스터마이징
class Custom_JWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        raw_token = request.COOKIES.get('JWT', None)
        if raw_token is None:
            return None

        validated_token = self.get_validated_token(raw_token)
        return self.get_user(validated_token), validated_token

class APITest(APIView):
    authentication_classes = [Custom_JWTAuthentication]

    def get(self, request, format=None):
        return Response({'message': 'success call api'})
  • set-cookie를 직접 사용해 보면서 쿠키에 대해 좀 더 알게된 것 같지만, cors 문제가 생기는 경우 브라우저에 쿠키가 설정되지 않는 문제점이 발견되었다.
    • CORS 관련 허용과 CREDENTIALS 설정을 해주어도 안되서 템플릿 자체를 서버에 넣어서 해결하긴 했으나.. 근본적인 해결책은 아닌것 같아서 조금 더 해봐야 할 것 같다.

'프레임워크 > Django' 카테고리의 다른 글

[ Django ] Session  (0) 2021.10.17
[ Django ] CSRFToken  (0) 2021.10.16
[ Django ] JWT  (0) 2021.10.14
[ Django ] Choice  (0) 2021.09.15
[ Django ] MySQL 연동  (0) 2021.09.14

+ Recent posts