spring transaction을 활용해 async로 데이터를 처리할 때의 이슈

2013-04-02 21:33

오늘 애플리케이션 개발할 때 retry는 어떤 식으로 구현하고 있나? (http://www.slipp.net/questions/122)에에) 대한 질문을 올렸다. 이 질문을 올린 후에 곰곰히 생각해 봤다. 지금까지 애플리케이션을 구현하면서 retry를 구현하는 경우가 많지 않았는데 유독 slipp.net 소스에 retry 구현이 많지 않은가? 분명 어딘가 문제가 있다는 생각이 들었다.

그래서 retry를 이렇게 많이 사용하게된 원인을 파악해 봤다. 그랬더니 페이스북으로 글을 전송할 때 가끔씩 정상적으로 동작하지 않아 문제를 해결하려다보니 retry를 무분별하게 사용하게 되었다는 것이 떠올랐다. 그렇다면 왜 페이스북으로 글을 전송할 때 문제가 발생했을까? 원인을 찾아보니 페이스북 전송에 문제가 있었던 것이 아니라 Async로 처리하는 부분에서 데이터베이스에 저장된 데이터가 정상적으로 조회되지 않는다는 것이 가장 이슈였다. 원인은 다음 상황에서 발생하고 있었다.

@Transactional
public class QnaService {
    public Question createQuestion(SocialUser loginUser, QuestionDto questionDto) {
        [...]


        if (questionDto.isConnected()) {
             facebookService.sendToQuestionMessage(loginUser, savedQuestion.getQuestionId());
        }
        return savedQuestion;
    }
}

위 소스 코드와 같이 글을 쓰는 시점에 페이스북으로 전송 상태이면 facebookService.sendToQuestionMessage()를 호출해 페이스북으로 글을 전송한다. facebookService.sendToQuestionMessage() 내부를 살펴보면 다음과 같다.

@Service
@Transactional
public class FacebookService {
    @Async
    public void sendToQuestionMessage(SocialUser loginUser, Long questionId) {
        Question question = questionRepository.findOne(questionId);
        if (question == null) {
            question = retryFindQuestion(questionId);
        }


        [...]
    }


}

위 소스 코드를 보면 알 수 있듯이 sendToQuestionMessage()는 Async로 동작하도록 구현하고 있다. 이 때 문제가 발생하는 부분은 questionRepository.findOne(questionId)를 호출할 때 Question 데이터가 null이 되는 경우가 가끔 발생한다는 것이 문제의 원인이었다. 이 문제에 대한 해결책으로 retry를 선택한 것이다. 하지만 위와 같이 retry를 하더라도 문제는 완전히 해결되지 않았다. 왜 이와 같은 현상이 발생하는지 살펴보자.

먼저 위와 같이 구현할 경우의 데이터 처리 상태를 파악해 보자.

QnaService.createQuestion()에서 Transaction이 시작된다.

데이터베이스에 Question 데이터를 저장한다. 하지만 아직까지 commit이 완료되지 않았기 때문에 데이터베이스에 완전히 저장된 것은 아니다.

facebookService.sendToQuestionMessage() 메소드를 호출해 페이스북에 메시지를 전송한다.

QnaService.createQuestion() 메소드가 종료되면서 commit을 한다.

위 과정에서 문제의 원인이 된 부분은 4번 과정이 완료되지 않은 상태에서 3번 과정이 먼저 실행되면서 4번에서 commit 후에 저장해야 될 Question 데이터를 조회하기 때문에 조회할 수 없는 상황이 발생한 것이다. 3번과 4번의 실행 순서가 어떻게 되느냐에 따라 기능이 정상 동작하거나 동작하지 않는 상황이 발생한 것이다.

이에 대한 문제를 인지하고 자료를 찾아보니 몇 일 전에 올라온 따끈따끈한 자료가 있어 참고해서 문제를 해결할 수 있었다.

http://architects.dzone.com/articles/synchronizing-transactions : 이틀 전에 올라온 글이다. ㅋㅋ

이 글을 참고해서 다음과 같이 코드를 수정했다.

public class QnaService {
    public Question createQuestion(final SocialUser loginUser, QuestionDto questionDto){
        Set<Tag> tags = tagService.processTags(questionDto.getPlainTags());


        Question newQuestion = new Question(loginUser, questionDto.getTitle(), questionDto.getContents(), tags);
        final Question savedQuestion = questionRepository.saveAndFlush(newQuestion);


        if (questionDto.isConnected()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                public void afterCommit() {
                    facebookService.sendToQuestionMessage(loginUser, savedQuestion.getQuestionId());
                }
            });
        }
        return savedQuestion;
    }
}

위 소스 코드와 같이 TransactionSynchronizationManager.registerSynchronization()를 활용해 구현해 봤다. 아직까지 모든 문제가 완료된 것인지는 확인하지 못했다. 당분간 운영해 보면 문제가 정상적으로 해결되었는지 확인해 볼 수 있을 듯하다. 이 이슈를 경험하면서 Async 기능을 구현할 때는 고려해야할 부분이 많다는 것을 다시 한번 느꼈다. 한 가지 기능에 대해 여러 개의 Thread가 동작하는 경우 Transaction이 어떻게 동작하는지 정확하게 이해하고 사용할 필요가 있을 것으로 생각한다.

0개의 의견 from FB

4개의 의견 from SLiPP

2013-04-03 11:27

@자바지기 retry의 근본 원인을 찾았으니 다행이네요. 그런데 저 같으면 좀 다른 접근법을 택할 것 같아요. 지금의 slipp DB는 slipp과 fb의 integration 용도로도 사용되고 있는데요. 두 시스템의 통합에 DB의존성을 없애고 싶네요.

slipp과 fb의 데이터 동기화가 완벽하다면 좋겠지만, 그냥 savedQuestion을 facebookService에 전달하면 안될까요?

if (questionDto.isConnected()) {
    facebookService.sendToQuestionMessage(loginUser, savedQuestion);
}

저는 annotation + aop를 좋아하는 편은 아닌데요. 편하기 때문에 문제를 너무 쉽게 생각하는 경향이 생기는 것 같아서요. 대신에 코드로 자세하고 정확하게 표현하는 방법을 찾는게 좋더라구요. 순전히 저의 취향이지만요ㅎㅎ

2013-04-03 12:32

@benghun 저도 처음에는 제안한 방법으로 구현했습니다. 그런데 이 부분도 Async로 동작하기 때문에 발생하는 이슈가 있었어요. 이슈는 Question에서 ORM의 Lazy loading을 사용하고 있는데요. 이 부분이 Async로 동작하기 때문에 서로 다른 Transaction이라 Session이 종료되어 버려서 Lazy Loading 기능이 정상적으로 동작하지 않는 이슈가 있었습니다. 그래서 위 코드와 같이 questionId를 전달해 다시 select하는 방식을 취했어요.

또 한가지 FacebookService.sendToQuestionMessage() 메소드 내부를 보면 다음과 같은 부분이 있습니다.

String postId = sendMessageToFacebook(loginUser, createLink(question.getQuestionId()), message);
if (postId != null) {
	question.connected(postId);
}

위와 같이 페이스북에 메시지를 전송한 후 저장된 postId를 slipp DB에 저장하고 있습니다. 이 부분은 지금 활용하고 있지 않지만 추후 slipp글과 페이스북 글이 연결되어 있는 경우 페이스북 글의 댓글도 보여주는데 활용하려고 생각하고 있습니다. slipp에도 좋은 답변이 많지만 페이스북에도 유용한 정보들이 많아서 같이 공유하면 좋겠다는 생각이 들어서요.

위와 같이 두 가지 이슈가 있는데요. 의견 주신대로 DB 의존성을 없앨 수는 있을 것으로 생각합니다.

첫번째 이슈는 Lazy Loading 부분을 QnaService에서 해결한 후에 필요한 데이터를 전달하면 됩니다. 이 부분은 그리 어려운 부분은 아니라 생각됩니다.

두번째 이슈인 Facebook과의 연결 정보를 저장하는 부분은 Question 테이블에 저장하지 않고 별도의 테이블에 저장하는 방식으로 구현한다면 둘 사이의 DB 의존성을 없앨 수 있을 것으로 생각합니다.

주신 의견 고려해서 추후 개선하는 시점에 고려해 보도록 할께요. 의견 감사합니다.

2013-04-03 14:16

@자바지기 @Transaction과 @Async를 혼용했을 경우 발생하는 이슈들이 꽤 많고 잘 드러나지도 않는 것 같네요. 그러고보니 제가 담당했던 서비스들에서는 트랜젝션을 사용한 적이 없었네요. @Transaction은 테스트에서만 썼었고 ㅎㅎ

ps 의견 추가하기창이 커져서 좋네요 ^^

의견 추가하기

연관태그

← 목록으로