Spring Boot에서는 전역적으로 적용되는 날짜 및 시간 형식을 지정할 수 있습니다. 이를 통해 코드에서 중복되는 날짜 및 시간 형식 코드를 제거하고 코드의 가독성과 유지 보수성을 향상시킬 수 있습니다.
여러가지 방안이 있겠습니다만 저는 아래같은 방식으로 진행해왔으니 참고하시면 될것 같습니다.
@JsonFormat 어노테이션
@Data
public class MyDateTime {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
private Date dateTime;
}
WebMvcConfigurer 인터페이스 구현
public class WebMvcConfig implements WebMvcConfigurer {
@Bean
public Jackson2ObjectMapperBuilder jacksonBuilder() {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.timeZone(TimeZone.getDefault());
return builder;
}
}
postman 으로 테스트 할 controller 를 하나 만들어보겠습니다.
@RestController
@RequestMapping("/")
public class dataController {
@GetMapping("/dateTime")
public MyDateTime getMyDateTime() {
MyDateTime myDateTime = new MyDateTime();
myDateTime.setDateTime(new Date());
return myDateTime;
}
}
@JsonComponent 어노테이션 사용하는 방법
CustomDateSerializer 를 작성합니다.
public class CustomDateSerializer extends JsonSerializer<Date> {
private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
@Override
public void serialize(Date value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
jgen.writeString(dateFormat.format(value));
}
}
CustomDateDeserializer를 작성 합니다.
public class CustomDateDeserializer extends JsonDeserializer<Date> {
private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
@Override
public Date deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
JsonNode node = jp.getCodec().readTree(jp);
String dateString = node.textValue();
try {
return dateFormat.parse(dateString);
} catch (ParseException e) {
throw new IOException("Invalid date format: " + dateString, e);
}
}
}
새로운 응답형식으로 객체 하나 더 만들어 볼게요.
@Data
public class MyResponse {
private String message;
private Date timestamp;
}
마지막으로 postman 이 호출할 controller 를 하나 추가로 만들어 볼게요.
public class MyController {
@GetMapping("/myEndpoint")
public MyResponse getMyResponse() {
MyResponse response = new MyResponse();
response.setMessage("안녕! 난 블레이크야!");
response.setTimestamp(new Date());
return response;
}
}
RequestBodyAdvice의 사용법에 대해 묻는 사람이 있어 이에 대해 글을 쓰게 되었습니다.
암호화 및 복호화 자체는 어렵지 않지만, 언제 처리해야 할까요? 필터를 정의하여 요청과 응답을 각각 가로채 처리하는 것도 가능하지만 이 방법은 거칠지만 유연합니다. 왜냐하면, 첫 번째 손에 요청 매개 변수와 응답 데이터를 얻을 수 있기 때문입니다. 그러나 SpringMVC에서는 ResponseBodyAdvice와 RequestBodyAdvice를 제공하여 요청과 응답을 사전 처리할 수 있도록 지원합니다.
그래서 오늘 이 글은 두 가지 목적을 가지고 있습니다:
매개 변수/응답 암호화 및 복호화에 대한 아이디어 공유.
ResponseBodyAdvice와 RequestBodyAdvice의 사용법을 공유합니다.
그러면 이제 본론으로 들어가 보겠습니다.
1.개발 암호화 starter
우리가 개발하는 이 도구를 보다 일반화하기 위해, 그리고 스프링 부트 스타터를 직접 정의하기 위해 이 도구를 starter로 만들겠습니다. 이후에는 Spring Boot 프로젝트에서 바로 사용할 수 있습니다.
먼저 Spring Boot 프로젝트를 생성하고, spring-boot-starter-web 의존성을 추가합니다.
의존성을 추가한 후에는 미리 사용할 수 있도록 암호화 유틸리티 클래스를 정의합니다. 암호화 방법에는 대칭 키 암호화와 비대칭 키 암호화가 있으며, 대칭 키 암호화에서는 AES, DES, 3DES 등 다양한 알고리즘이 사용됩니다. 여기에서는 Java에서 제공하는 Cipher를 사용하여 대칭 키 암호화 방식 중 AES 알고리즘을 사용합니다.
이 Utils 클래스는 간단하므로 따로 설명은 필요하지 않을것 같습니다. 그러나 암호화된 데이터는 가독성이 없을 수 있으므로 일반적으로 암호화된 데이터를 Base64 알고리즘을 사용하여 인코딩하여 읽을 수 있는 문자열을 얻을 수 있습니다. 다시 말해서, 위의 AES 암호화 메소드의 반환 값은 Base64로 인코딩 된 문자열입니다. AES 복호화 메소드의 매개 변수도 Base64로 인코딩 된 문자열이며, 이 문자열을 먼저 디코딩 한 다음 해독해야합니다.
그 다음 우리는 예비로 응답 도구 클래스를 패키징 할 것입니다. 이것은 사람들이 종종 송건 비디오를보고 이미 잘 이해할 것입니다.
public class RespBean {
private Integer status;
private String msg;
private Object obj;
public static RespBean build() {
return new RespBean();
}
public static RespBean ok(String msg) {
return new RespBean(200, msg, null);
}
public static RespBean ok(String msg, Object obj) {
return new RespBean(200, msg, obj);
}
public static RespBean error(String msg) {
return new RespBean(500, msg, null);
}
public static RespBean error(String msg, Object obj) {
return new RespBean(500, msg, obj);
}
private RespBean() {
}
private RespBean(Integer status, String msg, Object obj) {
this.status = status;
this.msg = msg;
this.obj = obj;
}
public Integer getStatus() {
return status;
}
public RespBean setStatus(Integer status) {
this.status = status;
return this;
}
public String getMsg() {
return msg;
}
public RespBean setMsg(String msg) {
this.msg = msg;
return this;
}
public Object getObj() {
return obj;
}
public RespBean setObj(Object obj) {
this.obj = obj;
return this;
}
}
@Decrypt 와 @Encrypt 를 만들어 볼게요.
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD,ElementType.PARAMETER})
public @interface Decrypt {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Encrypt {
}
혹은 사용자가 직접 암호화 키를 구성할 수 있기 때문에 EncryptProperties 클래스를 정의하여 사용자가 구성한 키를 읽도록 합니다.
@ConfigurationProperties(prefix = "spring.encrypt")
public class EncryptProperties {
private final static String DEFAULT_KEY = "https://rainsister.tistory.com/";
private String key = DEFAULT_KEY;
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
}
이후 key는 application.properties (혹은 application.yml) 파일에 선언해 놓으면 됩니다.
spring.encrypt.key=xxx // 이런 식으로
암호화
@EnableConfigurationProperties(EncryptProperties.class)
@ControllerAdvice
public class EncryptResponse implements ResponseBodyAdvice<RespBean> {
private ObjectMapper om = new ObjectMapper();
@Autowired
EncryptProperties encryptProperties;
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return returnType.hasMethodAnnotation(Encrypt.class);
}
@Override
public RespBean beforeBodyWrite(RespBean body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
byte[] keyBytes = encryptProperties.getKey().getBytes();
try {
if (body.getMsg()!=null) {
body.setMsg(AESUtils.encrypt(body.getMsg().getBytes(),keyBytes));
}
if (body.getObj() != null) {
body.setObj(AESUtils.encrypt(om.writeValueAsBytes(body.getObj()), keyBytes));
}
} catch (Exception e) {
e.printStackTrace();
}
return body;
}
}
supports: 이 메서드는 어떤 유형의 인터페이스가 암호화되어야 하는지를 판단하기 위해 사용됩니다. returnType 매개 변수는 반환 유형을 나타내며, 여기에서 판단 로직은 해당 인터페이스에 @Encrypt 주석이 포함되어 있는지 여부입니다. 있으면 해당 인터페이스는 암호화 처리가 필요하며, 없으면 해당 인터페이스는 암호화 처리가 필요하지 않습니다.
beforeBodyWrite: 이 메서드는 데이터 응답 전에 실행됩니다. 즉, 우리는 응답 데이터를 먼저 두 번째 처리하고 처리가 완료되면 json으로 반환합니다. 여기에서는 처리 방법이 매우 간단합니다. RespBean의 status는 상태 코드이므로 암호화하지 않아도됩니다. 두 개의 다른 필드를 다시 암호화하고 다시 값을 설정하면됩니다.
주의!사용자 정의 ResponseBodyAdvice는 @ControllerAdvice 주석을 사용하여 표시해야합니다.
복호화
@EnableConfigurationProperties(EncryptProperties.class)
@ControllerAdvice
public class DecryptRequest extends RequestBodyAdviceAdapter {
@Autowired
EncryptProperties encryptProperties;
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return methodParameter.hasMethodAnnotation(Decrypt.class) || methodParameter.hasParameterAnnotation(Decrypt.class);
}
@Override
public HttpInputMessage beforeBodyRead(final HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
byte[] body = new byte[inputMessage.getBody().available()];
inputMessage.getBody().read(body);
try {
byte[] decrypt = AESUtils.decrypt(body, encryptProperties.getKey().getBytes());
final ByteArrayInputStream bais = new ByteArrayInputStream(decrypt);
return new HttpInputMessage() {
@Override
public InputStream getBody() throws IOException {
return bais;
}
@Override
public HttpHeaders getHeaders() {
return inputMessage.getHeaders();
}
};
} catch (Exception e) {
e.printStackTrace();
}
return super.beforeBodyRead(inputMessage, parameter, targetType, converterType);
}
}
DecryptRequest 클래스에서는 RequestBodyAdvice 인터페이스를 직접 구현하는 대신 RequestBodyAdviceAdapter 클래스를 상속합니다. RequestBodyAdviceAdapter 클래스는 RequestBodyAdvice 인터페이스의 하위 클래스이며, 인터페이스의 일부 메서드를 구현하므로 RequestBodyAdviceAdapter를 상속하면 실제 요구 사항에 따라 일부 메서드만 구현하면됩니다.
supports: 이 메서드는 어떤 인터페이스가 인터페이스 복호화를 처리해야 하는지를 판단하기 위해 사용됩니다. 여기에서 판단 로직은 @Decrypt 주석이 메서드 또는 매개 변수에 포함되어 있는지 여부입니다. 복호화 문제를 처리합니다.
beforeBodyRead: 이 메서드는 매개 변수가 구체적인 객체로 변환되기 전에 실행됩니다. 먼저 데이터를 스트림에서 로드한 다음 데이터를 복호화하고 복호화가 완료되면 HttpInputMessage 객체를 다시 구성하여 반환합니다.
사용은 어떻게?
public class User {
private Long id;
private String username;
//생략 getter/setter
}
@RestController
public class HelloController {
@GetMapping("/user")
@Encrypt
public RespBean getUser() {
User user = new User();
user.setId((long) 99);
user.setUsername("javaboy");
return RespBean.ok("ok", user);
}
@PostMapping("/user")
public RespBean addUser(@RequestBody @Decrypt User user) {
System.out.println("user = " + user);
return RespBean.ok("ok", user);
}
}
@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으로 선언되지 않은 메서드에 적용되는 경우
@Transactional이 public으로 선언되지 않은 메서드에 적용된 경우
@Transactional이 public이 아닌 메소드에 적용되었을 경우에는 @Transactional이 작동하지 않는다. 이는 Spring AOP 프록시에서 발생하는데, TransactionInterceptor(트랜잭션 인터셉터)는 대상 메소드 실행 전후에 가로채며, DynamicAdvisedInterceptor(CglibAopProxy의 내부 클래스)의 intercept 메소드 또는 JdkDynamicAopProxy의 invoke 메소드는 간접적으로 AbstractFallbackTransactionAttributeSource의 computeTransactionAttribute 메소드를 호출하여 @Transactional의 트랜잭션 구성 정보를 가져옵니다.
computeTransactionAttribute 메소드는 대상 메소드의 수정자(modifier)가 public인지 여부를 확인하고, public이 아니라면 @Transactional 속성 구성 정보를 가져오지 않습니다.
주의: protected, private로 선언된 메서드에 @Transactional 어노테이션을 사용하더라도, 트랜잭션이 적용되지 않을 뿐만 아니라 오류 메시지도 나타나지 않으므로, 이 점에 대해서 주의해야 합니다.
@Transactional 어노테이션의 propagation 속성 설정 오류
이러한 실패는 구성 오류로 인해 발생하며, 아래 세 가지 propagation을 잘못 구성하면 트랜잭션이 롤백되지 않습니다.
TransactionDefinition.PROPAGATION_SUPPORTS: 현재 트랜잭션이 있는 경우 해당 트랜잭션에 참여하고, 트랜잭션이 없는 경우 트랜잭션 없이 계속 실행됩니다.
TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 트랜잭션 없이 실행되며, 현재 트랜잭션이 있는 경우 현재 트랜잭션을 일시 중지합니다.
TransactionDefinition.PROPAGATION_NEVER: 트랜잭션이 없이 실행되며, 현재 트랜잭션이 있는 경우 예외가 발생합니다.
@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;
}
}
동일한 클래스 내에서의 메서드 호출로 @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);
}
코드 중에 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 트랜잭션 매니저가 예외를 감지하여 트랜잭션을 롤백합니다.
마지막 케이스는 확률이 극히 낮습니다만…
Database 엔진 자체가 트랜젝션을 지원하지 않을경우…
Mysql 를 예로 들면 …
기본적으로 Innodb 를 사용하지만 , 수동으로 myisam 을 사용하는 환경일경우 트랜젝션 자체를 지원하지않습니다. 이부분은 db 설정했던 당사자한테 가서 확인해보는편이 더빠를것 같네요.
오늘의 결론…
뭐 양날의 검이다 라고 앞에서 어급했듯이 단순 @Transactional 을 하더라도 왜 ? 그리고 여기에 위치했을때 과연 적절한지를 판단하고 작성하도록 하시다.
@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() 메서드를 호출하기만 하면 됩니다.
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;
}
}
뭐 정상 작동되고 아무런 문제 없어 보입니다. 네 그렇습니다. 아무런 문제는 없죠. 하지만 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 {
}