728x90

양날의 검!

@Transactional 어노테이션은 개발에서 자주 사용됩니다. 이 어노테이션은 메소드 내에서 여러 개의 데이터베이스 작업이 모두 성공하거나 모두 실패하도록 보장합니다. @Transactional 어노테이션을 사용할 때는 많은 세부 사항을 고려해야하며, 그렇지 않으면 @Transactional이 항상 이상하게 실패한다는 것을 발견할 수 있습니다.

트랜잭션 개념만 살짝 언급, 깊게는 나도 몰랑!

트랜잭션 관리는 시스템 개발에서 불가결한 부분이며, Spring은 훌륭한 트랜잭션 관리 메커니즘을 제공합니다. 주로 프로그래밍 방식과 선언적 트랜잭션으로 구분됩니다.

프로그래밍 방식 트랜잭션: 코드에서 수동으로 커밋, 롤백 등 트랜잭션 조작을 하는 것을 말합니다. 코드 침입성이 상대적으로 강합니다. 아래는 예시입니다.

어떤 방식으로 제공되는가?

시스템 개발에서는 트랜잭션 관리가 필수적이며, Spring은 우수한 트랜잭션 관리 메커니즘을 제공합니다. 이 메커니즘은 프로그래밍 방식 트랜잭션과 선언적 트랜잭션 두 가지로 나뉩니다.

프로그래밍 방식

프로그래밍 방식 트랜잭션은 코드에서 수동으로 트랜잭션을 관리하는 것으로, 커밋, 롤백 등의 작업을 직접 처리합니다. 코드에 침투성이 높아지기 때문에 잘못 사용하면 문제가 발생할 수 있습니다. 다음은 예시입니다.

try {
    //TODO something
     transactionManager.commit(status);
} catch (Exception e) {
    transactionManager.rollback(status);
    throw new InvoiceApplyException("트랜잭션 처리 실패");
}

선언적 방식

선언적 트랜잭션은 AOP(Aspect-Oriented Programming) 기반으로 구현되어 비즈니스 로직과 트랜잭션 처리 부분을 분리하여 코드 침투성을 낮춥니다. 실제로 개발에서는 선언적 트랜잭션을 주로 사용합니다. 선언적 트랜잭션은 두 가지 방법으로 구현할 수 있습니다. 하나는 TX와 AOP를 사용하는 XML 설정 파일 방식이고, 다른 하나는 @Transactional 어노테이션을 사용하는 방식입니다. 예시는 다음과 같습니다.

@Transactional
@GetMapping("/test")
public String test() {
    int insert = cityInfoDictMapper.insert(cityInfoDict);
}

@Transactional 소개

@Transactional 어노테이션은 어디에 적용될 수 있나요? @Transactional 어노테이션은 인터페이스, 클래스, 클래스 메소드에 적용될 수 있습니다.
  • 클래스에 적용: @Transactional 어노테이션을 클래스에 적용하면, 클래스의 모든 public 메소드에 동일한 트랜잭션 속성 정보가 적용됩니다.
  • 메소드에 적용: 클래스가 @Transactional 어노테이션을 가지고 있고, 메소드도 @Transactional 어노테이션을 가지고 있다면, 메소드의 트랜잭션 정보가 클래스의 트랜잭션 정보보다 우선합니다.
  • 인터페이스에 적용: 이 방법은 권장되지 않습니다. 인터페이스에 @Transactional 어노테이션을 적용하고, Spring AOP가 CGLib 동적 프록시를 사용하는 경우 @Transactional 어노테이션을 잘못 처리할 수 있습니다.
@Transactional
@RestController
@RequestMapping
public class MybatisPlusController {
    @Autowired
    private CityInfoDictMapper cityInfoDictMapper;

    @Transactional(rollbackFor = Exception.class)
    @GetMapping("/test")
    public String test() throws Exception {
        CityInfoDict cityInfoDict = new CityInfoDict();
        cityInfoDict.setParentCityId(2);
        cityInfoDict.setCityName("2");
        cityInfoDict.setCityLevel("2");
        cityInfoDict.setCityCode("2");
        int insert = cityInfoDictMapper.insert(cityInfoDict);
        return insert + "";
    }
}

@Transactional 어노테이션 속성들…

propagation 속성: 트랜잭션 전파 동작을 나타냅니다. 기본값은 Propagation.REQUIRED입니다. 다른 속성 정보는 다음과 같습니다:
  • Propagation.REQUIRED: 현재 트랜잭션이 있으면 해당 트랜잭션에 참여하고, 없으면 새로운 트랜잭션을 생성합니다. (기본적으로 A 메서드와 B 메서드 두 개의 메서드에 주석이 추가되어 있으며, 기본 전파 모드에서 A 메서드 내에서 B 메서드를 호출하면 두 개의 메서드의 트랜잭션이 하나의 트랜잭션으로 병합됩니다.)
  • Propagation.SUPPORTS: 현재 트랜잭션이 존재하면 해당 트랜잭션에 참여하고, 없으면 트랜잭션 없이 계속 진행합니다.
  • Propagation.MANDATORY: 현재 트랜잭션이 있으면 해당 트랜잭션에 참여하고, 없으면 예외를 발생시킵니다.
  • Propagation.REQUIRES_NEW: 새로운 트랜잭션을 생성하고, 현재 트랜잭션이 존재하면 해당 트랜잭션을 일시 중단합니다. (A 클래스의 a 메서드가 기본 Propagation.REQUIRED 모드를 사용하고, B 클래스의 b 메서드가 Propagation.REQUIRES_NEW 모드를 사용하며, a 메서드에서 데이터베이스 작업을 수행하는 동안 예외가 발생하면, Propagation.REQUIRES_NEW는 a 메서드의 트랜잭션을 일시 중단하므로 b 메서드는 롤백되지 않습니다.)
  • Propagation.NOT_SUPPORTED: 트랜잭션이 없는 상태에서 진행하며, 현재 트랜잭션이 존재하면 해당 트랜잭션을 일시 중단합니다.
  • Propagation.NEVER: 트랜잭션이 없는 상태에서 진행하며, 현재 트랜잭션이 존재하면 예외를 발생시킵니다.
  • Propagation.NESTED: Propagation.REQUIRED와 동일한 효과가 있습니다.
isolation 속성: 트랜잭션 격리 수준을 지정합니다. 기본값은 Isolation.DEFAULT입니다.
  • Isolation.DEFAULT: 기본 데이터베이스 격리 수준을 사용합니다.
  • Isolation.READ_UNCOMMITTED
  • Isolation.READ_COMMITTED
  • Isolation.REPEATABLE_READ
  • Isolation.SERIALIZABLE
timeout 속성: 트랜잭션 제한 시간을 지정합니다. 기본값은 -1이며, 이는 시간 제한이 없음을 의미합니다. 만약 제한 시간을 초과하더라도 트랜잭션이 완료되지 않은 경우 자동으로 롤백됩니다.
readOnly 속성: 트랜잭션이 읽기 전용인지 여부를 지정합니다. 기본값은 false이며, 이는 트랜잭션이 읽기/쓰기 모두 가능함을 의미합니다. 읽기 작업만 수행하는 메소드와 같이 트랜잭션이 필요하지 않은 경우, readOnly를 true로 설정하여 성능을 개선할 수 있습니다.
rollbackFor 속성: 트랜잭션에서 롤백이 필요한 예외 타입을 지정합니다. 여러 예외 타입을 지정할 수 있습니다.
noRollbackFor 속성: 특정 예외 타입이 발생해도 롤백하지 않도록 지정할 수 있습니다. 여러 예외 타입을 지정할 수 있습니다.

@Transactional 어노테이션이 적용 안되는 케이스!

@Transactional이 public으로 선언되지 않은 메서드에 적용되는 경우
  1. @Transactional이 public으로 선언되지 않은 메서드에 적용된 경우

@Transactional이 public이 아닌 메소드에 적용되었을 경우에는 @Transactional이 작동하지 않는다. 이는 Spring AOP 프록시에서 발생하는데, TransactionInterceptor(트랜잭션 인터셉터)는 대상 메소드 실행 전후에 가로채며, DynamicAdvisedInterceptor(CglibAopProxy의 내부 클래스)의 intercept 메소드 또는 JdkDynamicAopProxy의 invoke 메소드는 간접적으로 AbstractFallbackTransactionAttributeSource의 computeTransactionAttribute 메소드를 호출하여 @Transactional의 트랜잭션 구성 정보를 가져옵니다.

protected TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) {
    // Don't allow no-public methods as required.
    if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
        return null;
    }

computeTransactionAttribute 메소드는 대상 메소드의 수정자(modifier)가 public인지 여부를 확인하고, public이 아니라면 @Transactional 속성 구성 정보를 가져오지 않습니다.

주의: protected, private로 선언된 메서드에 @Transactional 어노테이션을 사용하더라도, 트랜잭션이 적용되지 않을 뿐만 아니라 오류 메시지도 나타나지 않으므로, 이 점에 대해서 주의해야 합니다.
  1. @Transactional 어노테이션의 propagation 속성 설정 오류

이러한 실패는 구성 오류로 인해 발생하며, 아래 세 가지 propagation을 잘못 구성하면 트랜잭션이 롤백되지 않습니다.

  • TransactionDefinition.PROPAGATION_SUPPORTS: 현재 트랜잭션이 있는 경우 해당 트랜잭션에 참여하고, 트랜잭션이 없는 경우 트랜잭션 없이 계속 실행됩니다.
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 트랜잭션 없이 실행되며, 현재 트랜잭션이 있는 경우 현재 트랜잭션을 일시 중지합니다.
  • TransactionDefinition.PROPAGATION_NEVER: 트랜잭션이 없이 실행되며, 현재 트랜잭션이 있는 경우 예외가 발생합니다.
  1. @Transactional 어노테이션의 rollbackFor 속성 설정 오류

rollbackFor 속성을 사용하여 트랜잭션 롤백을 유발시킬 수 있는 예외 유형을 지정할 수 있습니다. Spring은 기본적으로 확인되지 않은 unchecked 예외 (RuntimeException에서 상속된 예외) 또는 오류(Error)만 롤백합니다. 다른 예외는 트랜잭션 롤백을 유발시키지 않습니다. 따라서 다른 유형의 예외가 트랜잭션에서 발생하더라도 Spring이 트랜잭션 롤백을 유발시키기를 원하는 경우 rollbackFor 속성을 지정해야합니다.

//MyException이 발생하면 트랜잭션을 롤백하도록 설정하고 싶을 때 사용하는 예시 코드입니다.
@Transactional(propagation= Propagation.REQUIRED,rollbackFor= MyException.class

재귀적으로 Throwable 클래스의 하위 클래스들을 탐색하면서 주어진 예외 클래스 이름을 찾는 메서드입니다.

private int getDepth(Class<?> exceptionClass, int depth) {
    // 만약 현재 클래스가 주어진 예외 클래스 이름을 포함하고 있다면 찾았다!
    if (exceptionClass.getName().contains(this.exceptionName)) {
        return depth;
    }
    // 만약 Throwable 클래스까지 갔는데도 찾지 못했다면 -1을 반환한다.
    if (exceptionClass == Throwable.class) {
        return -1;
    }
    // 현재 클래스의 슈퍼클래스로 재귀 호출한다.
    return getDepth(exceptionClass.getSuperclass(), depth + 1);
}

위 문장은 Spring Framework에서 @Transactional어노테이션에 rollbackFor속성이 지정된 경우, 해당 속성값으로 지정된 예외 클래스의 하위 클래스가 발생한 경우에도 트랜잭션이 롤백된다는 것을 설명하고 있습니다. 아래는 Spring Framework 소스 코드의 일부분입니다.

public void commit(TransactionStatus status) throws TransactionException {
    try {
        // 커밋 전에 트랜잭션 롤백 가능성을 체크한다.
        if (status.isRollbackOnly()) {
            if (status.isNewTransaction()) {
                // 롤백-전파 속성이 MANDATORY일 경우, 예외를 던진다.
                throw new IllegalTransactionStateException(
                        "Transaction marked as rollback-only but committed instead of rolling back");
            }
            // 현재 트랜잭션 롤백 처리
            rollback(status);
            return;
        }
        // ...
    } catch (RuntimeException ex) {
        // 롤백처리가 필요한 예외인지 체크한다.
        triggerRollbackIfNecessary(status, ex);
        throw ex;
    } catch (Error err) {
        // 롤백처리가 필요한 에러인지 체크한다.
        triggerRollbackIfNecessary(status, err);
        throw err;
    }
}
  1. 동일한 클래스 내에서의 메서드 호출로 @Transactional이 작동하지 않는 문제

개발 중에는 클래스 내부의 메서드 호출을 피할 수 없습니다. 예를 들어 Test 클래스에 메서드 A가 있고, A가 다시 클래스 내부의 메서드 B를 호출하는 경우 (B 메서드가 public 또는 private로 선언되었는지 상관없이), A 메서드에 트랜잭션 주석을 선언하지 않았지만 B 메서드에는 선언되어 있습니다. 그러면 외부에서 메서드 A를 호출한 후에도 메서드 B의 트랜잭션이 작동하지 않습니다. 이것은 종종 발생하는 오류 중 하나입니다.

그렇다면 왜 이런 상황이 발생하는 것일까요? 실제로 이는 Spring AOP 프록시를 사용하면서 발생하는 문제입니다. Spring에서 생성한 프록시 객체가 관리하는 것은 현재 클래스 외부의 코드에서 트랜잭션 메서드가 호출될 때만 해당되기 때문입니다.

@Transactional
@GetMapping("/test")
private Integer A() throws Exception {
    CityInfoDict cityInfoDict = new CityInfoDict();
    cityInfoDict.setCityName("2");
    /**
    * B 메서드를 호출하여 3의 값을 가진 데이터를 삽입합니다.
    /
    this.insertB();
    /*
    * A 메서드에서 2의 값을 가진 데이터를 삽입합니다.
    */
    int insert = cityInfoDictMapper.insert(cityInfoDict);
    return insert;
}

@Transactional()
public Integer insertB() throws Exception {
    CityInfoDict cityInfoDict = new CityInfoDict();
    cityInfoDict.setCityName("3");
    cityInfoDict.setParentCityId(3);
    return cityInfoDictMapper.insert(cityInfoDict);
}

  1. 코드 중에 catch 가 있는 지 잘 살펴봐라. 어쩌면 이놈이 ‘먹어’ 버렸을수도..
@Transactional
private Integer A() throws Exception {
    int insert = 0;
    CityInfoDict cityInfoDict = new CityInfoDict();
    cityInfoDict.setCityName("2");
    cityInfoDict.setParentCityId(2);
    try {
        /**
         * A 메서드 : 필드값이 2인 데이터 삽입
         */
        insert = cityInfoDictMapper.insert(cityInfoDict);
        /**
         * B 메서드 : 필드값이 3인 데이터 삽입
         */
        b.insertB();
    } catch (Exception e) {
        e.printStackTrace();
    }
    return insert;
}

위 코드를 보면 아래와 같은 질문이 있을것 입니다.

질문: B 메스드 내부에서 exception 이 발생한다면, 이때 A메서드가 B 메서드를 catch 했다면, 이때 과연 정상적으로 transaction 이 rollback 될까요?

답은 : NO 입니다.

아래와 같이 예외발생!

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

정답은 A와 B 메서드 간에 트랜잭션 일관성을 유지하려면 메서드 B 내에서 발생한 예외를 처리하는 대신 throw하여 A 메서드 호출자가 처리하도록해야합니다.입니다.

코드를 수정해보았습니다.

@Transactional
private Integer A() throws Exception {
    int insert = 0;
    CityInfoDict cityInfoDict = new CityInfoDict();
    cityInfoDict.setCityName("2");
    cityInfoDict.setParentCityId(2);
    try {
        /**
         * A 메서드: 2번 필드 삽입
         */
        insert = cityInfoDictMapper.insert(cityInfoDict);
        /**
         * B 메서드: 3번 필드 삽입
         */
        b.insertB();
    } catch (Exception e) {
        // 예외를 기록하고 처리하지 않습니다.
        throw e;
    }
    return insert;
}

이 수정된 코드에서는 B 메서드에서 예외가 발생하면 호출자인 A 메서드에게 전달되고, Spring 트랜잭션 매니저가 예외를 감지하여 트랜잭션을 롤백합니다.

마지막 케이스는 확률이 극히 낮습니다만…

  1. Database 엔진 자체가 트랜젝션을 지원하지 않을경우…

Mysql 를 예로 들면 …

기본적으로 Innodb 를 사용하지만 , 수동으로 myisam 을 사용하는 환경일경우 트랜젝션 자체를 지원하지않습니다. 이부분은 db 설정했던 당사자한테 가서 확인해보는편이 더빠를것 같네요.

오늘의 결론…

양날의 검이다 라고 앞에서 어급했듯이 단순 @Transactional 을 하더라도 왜 ? 그리고 여기에 위치했을때 과연 적절한지를 판단하고 작성하도록 하시다.


내저장소 바로가기 luxury515

728x90

이번 글에서는 코드의 중복 문제와 코드 재사용성에 대한 문제점을 살펴보겠습니다.

@RestController
@RequestMapping
public class UserController {
	@RequestMapping("addUser")
	public String addUser(UserParam userParam) {
	
	    if (StringUtils.isEmpty(userParam.getUserName())) {
	        return "사용자 이름을 입력해주세요";
	    }
	    if (StringUtils.isEmpty(userParam.getPhone())) {
	        return "휴대폰 번호를 입력해주세요";
	    }
	    if (userParam.getPhone().length() > 11) {
	        return "휴대폰 번호는 11자리를 초과할 수 없습니다";
	    }
	    if (StringUtils.isEmpty(userParam.getEmail())) {
	        return "이메일을 입력해주세요";
	    }
	
	    // 다른 매개 변수 검사를 생략했습니다
	
	    // todo : 사용자 정보 테이블에 삽입
	    return "SUCCESS";
	}
}

위의 코드는 사용자를 추가하는 기능과 사용자 정보를 편집하는 기능이 있습니다. 두 가지 기능 모두 사용자의 정보를 검증하고, 그 결과에 따라 성공 또는 실패 메시지를 반환합니다.

그러나 두 기능 모두 동일한 검증 로직을 갖고 있습니다. 따라서 두 메서드에서 중복 코드가 발생합니다. 이렇게 중복 코드가 발생하면 유지 보수가 어려워질 뿐만 아니라, 코드의 재사용성도 떨어지게 됩니다.

이러한 문제를 해결하기 위해 코드의 중복을 제거하고 코드의 재사용성을 높이기 위해 검증 로직을 별도의 메서드로 분리해야 합니다. 이렇게 하면 두 메서드에서 동일한 검증 로직을 반복하지 않아도 되므로 유지 보수가 용이해집니다.

따라서 검증 로직을 별도의 메서드로 분리하여 다음과 같이 코드를 수정할 수 있습니다:

개선 된 코드
@RestController
@RequestMapping
public class UserController {
	@RequestMapping("addUser")
	public String addUser(UserParam userParam) {
	    String validationMessage = validateUserParam(userParam);
	    if (validationMessage != null) {
	        return validationMessage;
	    }
	    // todo: insert user information into database
	    return "SUCCESS";
	}
	
	@RequestMapping("editUser")
	public String editUser(UserParam userParam) {
	    String validationMessage = validateUserParam(userParam);
	    if (validationMessage != null) {
	        return validationMessage;
	    }
	    // todo: update user information in database
	    return "SUCCESS";
	}
	
	private String validateUserParam(UserParam userParam) {
	    if (StringUtils.isEmpty(userParam.getUserName())) {
	        return "사용자 이름을 입력하세요.";
	    }
	    if (StringUtils.isEmpty(userParam.getPhone())) {
	        return "전화번호를 입력하세요.";
	    }
	    if (userParam.getPhone().length() > 11) {
	        return "전화번호는 11자 이하여야 합니다.";
	    }
	    if (StringUtils.isEmpty(userParam.getEmail())) {
	        return "이메일을 입력하세요.";
	    }
	    // other validation logic
	    return null;
	}
}

이렇게 코드를 수정하면 중복 코드를 제거하고 코드의 재사용성을 높일 수 있습니다. 이제 검증 로직이 필요한 모든 메서드에서 validateUserParam() 메서드를 호출하기만 하면 됩니다.

우리는 validation 라이브러리를 설치하고 조금 더 쉽게 유효값을 체크 할수 있다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
 </dependency>

이후 아래와 같이 코딩 할수 있다.

public class UserParam {
	@NotNull(message = "사용자 이름은 필수 입력사항입니다.")
	private String userName;
	
	@NotNull(message = "핸드폰 번호는 필수 입력사항입니다.")
	@Max(value = 11, message = "11자리 이내의 핸드폰 번호를 입력해주세요.")
	private String phone;
	
	@NotNull(message = "이메일은 필수 입력사항입니다.")
	private String email;
	
	// 생략...
	...

}

아래는 @Validated 어노테이션이 추가된 UserParam 파라미터 객체를 사용하여 BindingResult 객체에 에러 메시지를 수신하는 코드입니다.

@RequestMapping("addUser")
public String addUser(@Validated UserParam userParam, BindingResult result) {
    
    List<FieldError> fieldErrors = result.getFieldErrors();
    if (!fieldErrors.isEmpty()) {
        return fieldErrors.get(0).getDefaultMessage();
    }

    //todo: 사용자 정보를 데이터베이스에 삽입

    return "SUCCESS";
}

인터페이스 통일 응답 객체 반환

프로젝트 코드에서 컨트롤러 레이어의 응답 결과를 보면 다음과 같은 것이 있을 수 있습니다.

@RequestMapping("/hello")
public String getStr(){
return "hello, 산달라떼를 좋아하는 소년";
}

// 반환:
hello, 산달라떼를 좋아하는 소년

또는 다음과 같은 것도 있을 수 있습니다.

@RequestMapping("queryUser")
public UserVo queryUser(String userId) {
return new UserVo("666", "산달라떼를 좋아하는 소년");
}
// 반환:
{"userId":"666","name":"산달라떼를 좋아하는 소년"}

인터페이스 반환 결과가 일치하지 않으면, 프론트엔드 처리가 불편하며, 코드도 유지보수하기 어려워집니다. 예를 들어 작은 달팽이는 결과를 처리할 때 Result를 사용하고, 큰 달팽이는 Response를 사용하는 것을 좋아합니다. 이러한 코드가 얼마나 혼란스러울지 상상할 수 있습니다.

그래서 백엔드 개발자로서, 프로젝트의 응답 결과는 통일된 표준 반환 형식이 필요합니다. 일반적으로 표준 응답 메시지 객체는 어떤 속성을 가지고 있나요?

code: 응답 코드 message: 응답 결과 data: 반환 데이터

응답 코드는 일반적으로 열거형으로 표시

public enum CodeEnum {
	/**작업 성공**/
	SUCCESS("0000","작업 성공"),
	/**작업 실패**/
	ERROR("9999","작업 실패"),;
	
	/**
	 * 사용자 정의 상태 코드
	 **/
	private String code;
	/**사용자 정의 설명**/
	private String message;
	
	CodeEnum(String code, String message){
	    this.code = code;
	    this.message = message;
	}
	
	public String getCode() {
	    return code;
	}
	public String getMessage() {
	    return message;
	}
}

응답객체를 한번 만들어보겠습니다.

public class BaseResponse<T> {
    
    // 상태 코드 (0000: 성공, 9999: 실패)
    private String code;
    
    // 결과 메시지
    private String message;
    
    // 반환할 데이터
    private T data;
    
    // 성공 응답 생성
    public static <T> BaseResponse<T> success(T data) {
        BaseResponse<T> response = new BaseResponse<>();
        response.setCode(CodeEnum.SUCCESS.getCode());
        response.setMessage(CodeEnum.SUCCESS.getMessage());
        response.setData(data);
        return response;
    }
    
    // 실패 응답 생성
    public static <T> BaseResponse<T> fail(String code, String message) {
        BaseResponse<T> response = new BaseResponse<>();
        response.setCode(code);
        response.setMessage(message);
        return response;
    }
    
    // 코드 설정
    public void setCode(String code) {
        this.code = code;
    }
    
    // 메시지 설정
    public void setMessage(String message) {
        this.message = message;
    }
    
    // 데이터 설정
    public void setData(T data) {
        this.data = data;
    }
}

controller 를 한번 더 개선해보 겠습니다.

@RequestMapping("/hello")
public BaseResponse<String> getStr(){
return BaseResponse.success("hello,산달라떼를 좋아하는 소년");
}
// 출력 결과
{"code":"0000","message":"작업 성공","data":"hello,산달라떼를 좋아하는 소년"}

@RequestMapping("queryUser")
public BaseResponse<UserVo> queryUser(String userId) {
return BaseResponse.success(new UserVo("666", "산달라떼를 좋아하는 소년"));
}
// 출력 결과
{"code":"0000","message":"작업 성공","data":{"userId":"666","name":"산달라떼를 좋아하는 소년"}}

아참 그리고 예외처리 부분도 공통으로 한번 해보겠습니다.

public class BizException extends RuntimeException {

    private String retCode;

    private String retMessage;

    public BizException() {
        super();
    }

    public BizException(String retCode, String retMessage) {
        this.retCode = retCode;
        this.retMessage = retMessage;
    }

    public String getRetCode() {
        return retCode;
    }

    public String getRetMessage() {
        return retMessage;
    }
}

controller 쪽에서 아래와 같은 코드가 있을겁니다.

@RequestMapping("/query")
public BaseResponse<UserVo> queryUserInfo(UserParam userParam) {
     try {
        return BaseResponse.success(userService.queryUserInfo(userParam));
    } catch (BizException e) {
        //doSomething
    } catch (Exception e) {
        //doSomething
    }
    return BaseResponse.fail(CodeEnum.ERROR.getCode(),CodeEnum.ERROR.getMessage());
}

뭐 정상 작동되고 아무런 문제 없어 보입니다. 네 그렇습니다. 아무런 문제는 없죠. 하지만 try …catch 블럭이 너무 많을것으로 예상됩니다. 로직이 더 복잡해진다고 하면 5개 넘어 갈듯합니다.

spring의 @RestControllerAdvice 를 이용하여 Controller의 AOP 를 작성해 볼겠습니다. 일반적으로 @RestControllerAdvice 와 @ExceptionHandler를 결합해서 사용합니다.

@RestController
public class UserController {
	@Autowired
	private UserService userService;
	
	// 사용자 정보 조회 API
	@RequestMapping("/query")
	public BaseResponse<UserVo> queryUserInfo1(UserParam userParam) {
	    return BaseResponse.success(userService.queryUserInfo(userParam));
	}
}

@Service
public class UserServiceImpl implements UserService {
	// 사용자 정보 조회 메소드에서 발생할 수 있는 비즈니스 예외 처리
	@Override
	public UserVo queryUserInfo(UserParam userParam) throws BizException {
	throw new BizException("6666", "테스트 예외 클래스");
	}
}

@RestControllerAdvice 을 추가 합니다.

@RestControllerAdvice(annotations = RestController.class)
public class ControllerExceptionHandler {
}

추가적으로 BizException 타입을 가로채고 싶다면, @ExceptionHandler 어노테이션으로 수정한 새로운 메서드를 추가합니다.

@RestControllerAdvice(annotations = RestController.class)
public class ControllerExceptionHandler {
	// 비즈니스 예외 처리 메소드
	@ExceptionHandler(BizException.class)
	@ResponseBody
	public BaseResponse<Void> handler(BizException e) {
	    System.out.println("비즈니스 예외 발생: " + e.getRetCode() + " " + e.getRetMessage());
	    return BaseResponse.fail(CodeEnum.ERROR.getCode(), CodeEnum.ERROR.getMessage());
	}
}

결론적으로

여기서 알수 있는 것들:

  • 더욱 우아하고 간결하며 유지보수하기 쉬운 코드를 작성하기 위해서는 매개변수 유효성 검사, 통일된 응답 객체 반환, 예외 처리를 통일해야 합니다.
  • 매개변수 유효성 검사를 보다 간결하게 작성하기 위해서는 어노테이션을 사용할 수 있습니다.
  • 통일된 응답 객체는 일반적으로 상태 코드, 설명 메시지 및 반환 데이터를 포함해야 합니다.
  • 컨트롤러 레이어에서 전체 예외 처리를 통일하기 위해서는 @RestControllerAdvice와 @ExceptionHandler를 함께 사용할 수 있습니다.
  • 추가적으로, 진보된 사용자는 자체적으로 커스텀 어노테이션을 구현할 수 있으며, @RestControllerAdvice의 구현 원리를 살펴보는 것이 좋습니다. 이는 사실상 하나의 aspect 어노테이션으로 작동하기 때문입니다.


내저장소 바로가기 luxury515

728x90

일단 프로젝트에 필터(Filter)와 인터셉터(Interceptor)를 한꺼번에 만들어보겠습니다.

들어가면서 …

둘 다 웹 어플리케이션에서 HTTP 요청/응답을 가로채고 처리하는 역할을 합니다. 하지만 이 둘은 동작 방식과 사용 목적이 약간씩 다릅니다. 아래 표는 필터와 인터셉터의 차이점을 간단히 비교한 것입니다.

일단 요약부터 해보겠습니다.

구분필터(Filter)인터셉터(Intercepter)
위치서블릿 컨테이너 앞단서블릿 컨테이너 내부
등록web.xml 파일 또는 @WebFilter 어노테이션@Configuration 클래스 또는 @Component 어노테이션
적용 대상URL 패턴컨트롤러 또는 뷰 렌더링
작업 시기요청/응답 처리 전후컨트롤러 실행 전후, 뷰 렌더링 전후
필요한 초기화 작업필요함불필요함
작업 수행 방식ServletRequest, ServletResponse, FilterChain 사용HttpServletRequest, HttpServletResponse, Object, ModelAndView 사용

언제 어디서 어떻게 쓰이는가? 알고 싶지 않음?

실제로 필터와 인터셉터는 주로 로깅, 인코딩 설정, 보안, 세션 처리 등과 같은 공통적인 작업을 처리하는 데에 사용됩니다. 다만, 필터는 모든 URL 패턴에 대해 적용되므로 세밀한 제어가 어렵지만, 인터셉터는 컨트롤러나 뷰 렌더링에 대해 적용되므로 세밀한 제어가 가능합니다. 필터와 인터셉터는 상황에 맞게 선택하여 사용하는 것이 좋습니다.

필터 예시 코드

@WebFilter(filterName = "MyFilter", urlPatterns = { "/*" })
public class MyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // 요청 처리 전 필터 작업 수행
        String encoding = request.getCharacterEncoding();
        if (encoding == null) {
            request.setCharacterEncoding("UTF-8");
        }
        // 다음 필터 또는 서블릿 호출
        chain.doFilter(request, response);
        // 응답 처리 후 필터 작업 수행
        response.setCharacterEncoding("UTF-8");
    }
}

위 예시에서는 모든 URL 패턴에 대해 MyFilter라는 이름의 필터를 등록하고 있습니다. doFilter() 메소드는 요청 처리 전 필터 작업과 응답 처리 후 필터 작업 사이에서 chain.doFilter()를 호출하여 요청/응답 데이터를 처리합니다.

인터셉터 예시 코드

public class MyInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 컨트롤러 실행 전 작업 수행
        String path = request.getRequestURI();
        if (path.contains("/admin/")) {
            // 로그인이 필요한 페이지로 접근할 경우 로그인 페이지로 이동
            response.sendRedirect("/login");
            return false;
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) throws Exception {
        // 컨트롤러 실행 후 뷰 렌더링 전 작업 수행
        if (modelAndView != null) {
            modelAndView.addObject("user", request.getSession().getAttribute("user"));
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
            Exception ex) throws Exception {
        // 뷰 렌더링 후 작업 수행
    }
}

위 예시에서는 MyInterceptor라는 이름의 인터셉터를 등록하고 있습니다. preHandle() 메소드에서는 관리자 페이지에 접근하는 경우 로그인 페이지로 이동하는 작업을 수행하고, postHandle() 메소드에서는 컨트롤러에서 사용할 모델에 세션에서 가져온 사용자 정보를 추가하는 작업을 수행합니다.

요약하면…

요약하자면, 필터는 서블릿 컨테이너 앞단에서 실행되고, 웹 애플리케이션에서 발생하는 모든 요청과 응답에 대해 처리를 수행합니다. 반면 인터셉터는 서블릿 컨테이너 내부에서 실행되고, 주로 컨트롤러 처리 전후에 작업을 수행합니다.

필터와 인터셉터는 모두 요청/응답 처리에 대한 추가 작업을 수행할 수 있는데, 필터는 URL 패턴에 대해 적용되고, 인터셉터는 컨트롤러나 뷰 렌더링에 대해 적용됩니다. 또한 필터는 초기화 작업이 필요하지만, 인터셉터는 빈으로 등록하여 간단하게 사용할 수 있습니다.


내저장소 바로가기 luxury515

728x90

나는 java 언어를 주로 다루는 백엔드 개발자 이다. 그냥 개발자이다. 백기선 님 , 김영한 님 이런 유명 개발자가 아닌 그냥 똥 개발자이다. 그럼에도 불구하고 직렬화 와 역질렬화에 대한 개발기초 지식은 있으니 이 글을 읽는 개발자 분들도 어느정도 알거라 믿고 닥치고 RUST 에서 직렬화 , 역질화 다루는 법을 알아보자.

Serde 란?

Serde는 Rust 데이터 구조를 직렬화 및 역직렬화하는 라이브러리입니다. 이는 JSON, BSON, YAML 등 다양한 형식을 지원하며, 직렬화 및 역직렬화 방법을 사용자 정의할 수 있습니다. Serde는 간결하고 사용하기 쉬우며 효율적인 성능을 가지고 있어 Rust 생태계에서 가장 인기있는 직렬화 라이브러리 중 하나입니다.

기본 사용법

  • 설치

Rust 프로젝트에서 Serde를 사용하려면 Cargo.toml 파일에 다음 종속성을 추가해야 합니다.

[dependencies]
serde = { version = "1.0", features = ["derive"] }

여기서 features = ["derive"]는 Serde의 파생 매크로를 사용하며, 직렬화 및 역직렬화 코드를 자동으로 생성할 수 있습니다.

  • 직렬화

Serde를 사용하여 직렬화하려면 먼저 데이터 구조를 serde::Serialize trait로 구현해야 합니다. 예를 들어, 이름과 나이 두 가지 필드를 포함하는 Animal 구조체를 정의합니다.

#[derive(Serialize)]
struct Animal {
name: String,
age: u32,
}

그런 다음 serde_json 라이브러리를 사용하여 Animal 구조체를 JSON 문자열로 직렬화할 수 있습니다.

use serde_json;
let animal = Animal {
name: "Tom".to_owned(),
age: 3,
};
let json = serde_json::to_string(&animal).unwrap();
println!("{}", json); // {"name":"Tom","age":3}
  • 역직렬화

Serde를 사용하여 역직렬화하려면 먼저 데이터 구조를 serde::Deserialize trait로 구현해야 합니다. 예를 들어, 이름과 나이 두 가지 필드를 포함하는 Animal 구조체를 정의합니다.

#[derive(Deserialize)]
struct Animal {
name: String,
age: u32,
}

그런 다음 serde_json 라이브러리를 사용하여 JSON 문자열을 Animal 구조체로 역직렬화할 수 있습니다.

use serde_json;
let json = r#"{"name":"Tom","age":3}"#;
let animal: Animal = serde_json::from_str(json).unwrap();
println!("{:?}", animal); // Animal { name: "Tom", age: 3 }

직렬화 및 역직렬화를 사용자 정의하는 방법을 보여주는 Rust 코드입니다.

use serde::{Serialize, Deserialize, Serializer, Deserializer};

#[derive(Serialize, Deserialize)]
struct Animal {
    #[serde(serialize_with = "serialize_name", deserialize_with = "deserialize_name")]
    name: String,
    age: u32,
}

fn serialize_name<S>(name: &String, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(&name.to_uppercase())
}

fn deserialize_name<'de, D>(deserializer: D) -> Result<String, D::Error>
where
    D: Deserializer<'de>,
{
    let name = String::deserialize(deserializer)?;
    Ok(name.to_lowercase())
}

위의 Rust 코드는 Animal 구조체를 정의하고 이름(name)과 나이(age) 두 개의 필드를 포함합니다. 하지만 직렬화(Serialization)할 때 이름을 대문자로 변환하고, 역직렬화(Deserialization)할 때 이름을 소문자로 변환하도록 사용자 지정 직렬화 및 역직렬화 방법을 사용하고자 합니다.

Animal 구조체 내에서 #[serde(serialize_with = "serialize_name", deserialize_with = "deserialize_name")]를 사용하여 사용자 지정 직렬화 및 역직렬화 방법을 지정합니다. serialize_name 함수는 이름을 대문자로 변환하고 deserialize_name 함수는 이름을 소문자로 변환합니다.

또한 Serde는 열거형(Enum) 타입의 직렬화 및 역직렬화를 지원합니다. Animal 열거형을 정의하고, 개(Dog)와 고양이(Cat) 두 가지 유형을 포함시킵니다.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
enum Animal {
    Dog { name: String, age: u32 },
    Cat { name: String, age: u32 },
}

열거형(Enum) 타입을 직렬화 및 역직렬화할 때, #[serde(tag = "type")]를 사용하여 열거형(Enum) 타입의 레이블을 지정해야 합니다. 예를 들어:

use serde_json;

let dog = Animal::Dog { name: "Tom".to_owned(), age: 3 };
let json = serde_json::to_string(&dog).unwrap();
println!("{}", json); // {"type":"Dog","name":"Tom","age":3}

let json = r#"{"type":"Dog","name":"Tom","age":3}"#;
let dog: Animal = serde_json::from_str(json).unwrap();
println!("{:?}", dog); // Dog { name: "Tom", age: 3 }

위 코드는 Animal 열거형의 Dog 타입을 생성하고 이름이 "Tom"이며 나이가 3인 개(Dog)를 만듭니다.

해당 값을 JSON 형식으로 직렬화(serialization)하여 출력합니다. 그리고 다시 JSON 문자열을 역직렬화(deserialization)하여 Animal 열거형의 값을 얻습니다. 이렇게 출력된 결과를 확인해보면, Dog 타입이 생성되고, 이름은 "Tom", 나이는 3인 Animal이 만들어진 것을 확인할 수 있습니다.

Option 타입 직렬화 및 역직렬화

Serde는 Option 타입의 직렬화와 역직렬화를 지원합니다. 예를 들어, Animal 구조체를 정의할 때, name 필드는 None일 수 있습니다.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Animal {
    name: Option<String>,
    age: u32,
}

Option 값을 직렬화하려면, skip_serializing_if 속성을 사용하여 None일 때 해당 값을 생략하도록 지정해야 합니다.

use serde_json;

let animal = Animal { name: Some("Tom".to_owned()), age: 3 };
let json = serde_json::to_string(&animal).unwrap();
println!("{}", json); // {"name":"Tom","age":3}

let animal = Animal { name: None, age: 3 };
let json = serde_json::to_string(&animal).unwrap();
println!("{}", json); // {"age":3}

Vec 타입 직렬화 및 역직렬화

Serde는 Vec 타입의 직렬화와 역직렬화를 자동으로 처리합니다. 예를 들어, Zoo 구조체를 정의할 때, 여러 개의 Animal이 포함될 수 있습니다.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Zoo {
    animals: Vec<Animal>,
}
  • Vec 값을 직렬화하려면, serde_json::to_string 함수를 호출하면 됩니다.
let zoo = Zoo { animals: vec![
    Animal { name: "Tom".to_owned(), age: 3 },
    Animal { name: "Jerry".to_owned(), age: 2 },
] };
let json = serde_json::to_string(&zoo).unwrap();
println!("{}", json); // {"animals":[{"name":"Tom","age":3},{"name":"Jerry","age":2}]}
  • Vec 값을 역직렬화하려면, serde_json::from_str 함수를 호출하면 됩니다.
let json = r#"{"animals":[{"name":"Tom","age":3},{"name":"Jerry","age":2}]}"#;
let zoo: Zoo = serde_json::from_str(json).unwrap();
println!("{:?}", zoo); // Zoo { animals: [Animal { name: "Tom", age: 3 }, Animal { name: "Jerry", age: 2 }] }

Serde는 HashMap 타입의 직렬화 및 역직렬화를 지원합니다. 예를 들어, 다음과 같이 HashMap을 사용하여 여러 Animal을 저장하는 Zoo 구조체를 정의할 수 있습니다.

rustCopy code
use std::collections::HashMap;
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Zoo {
    animals: HashMap<String, Animal>,
}

#[derive(Serialize, Deserialize)]
struct Animal {
    name: String,
    age: u32,
}

HashMap을 포함한 구조체를 직렬화 및 역직렬화 할 때, Serde는 자동으로 처리합니다. 예를 들어 다음과 같이 HashMap에 "Tom"과 "Jerry"라는 두 개의 key-value 쌍을 추가하고, 이 HashMap을 Zoo 구조체에 저장한 다음, 이를 JSON 문자열로 직렬화할 수 있습니다.

use serde_json;
let mut animals = HashMap::new();
animals.insert("Tom".to_owned(), Animal { name: "Tom".to_owned(), age: 3 });
animals.insert("Jerry".to_owned(), Animal { name: "Jerry".to_owned(), age: 2 });

let zoo = Zoo { animals };
let json = serde_json::to_string(&zoo).unwrap();
println!("{}", json); // {"animals":{"Jerry":{"name":"Jerry","age":2},"Tom":{"name":"Tom","age":3}}}

이어서, JSON 문자열을 Zoo 구조체로 역직렬화할 수 있습니다.

let json = r#"{"animals":{"Jerry":{"name":"Jerry","age":2},"Tom":{"name":"Tom","age":3}}}"#;
let zoo: Zoo = serde_json::from_str(json).unwrap();
println!("{:?}", zoo); // Zoo { animals: {"Tom": Animal { name: "Tom", age: 3 }, "Jerry": Animal { name: "Jerry", age: 2 }} }

챕터를 마치며…

이 튜토리얼에서는 Serde를 사용하여 직렬화와 역직렬화하는 방법을 소개하고, 사용자 정의 직렬화 및 역직렬화 로직을 작성하는 방법을 설명합니다. Serde를 사용하면 Rust 데이터 구조를 어떤 형식으로든 쉽게 변환할 수 있으며, 사용자 정의 직렬화 및 역직렬화 로직을 통해 더 고급 기능을 구현할 수 있습니다.


내저장소 바로가기 luxury515

+ Recent posts