트랜젝션

 

데이터를 저장할 때 단순 파일이 아닌 데이터베이스에 저장하는 가장 큰 이유는 데이터베이스가 트랜젝션이라는 개념을 지원하기 때문이다.

트랜젝션은 데이터베이스에서 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻하는데 트랜젝션 기능을 사용하면 중간에 문제가 생길 경우 시작 이전으로 되돌릴 수 있다. 작업이 완료되고 데이터베이스에 정상 반영되는 것을 커밋(Commit)이라 하고, 문제가 생겨서 작업 이전으로 되돌리는 것을 롤백(Rollback)이라 한다.

 

 

트랜젝션 ACID 

 

트랜젝션은 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)를 보장해야 한다.

  • 원자성: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업처럼 모두 성공 하거나 모두 실패해야 한다.
  • 일관성: 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
  • 격리성: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(JPA  16.1 트랜잭션과 락 참고)을 선택할 수 있다.
  • 지속성: 성공적으로 트랜젝션이 끝나면 그 결과가 항상 기록되어야 한다중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.

 


 

데이터베이스 연결 구조와 DB 세션

 

데이터베이스는 커넥션을 연결할 때 내부에 DB 세션을 생성(커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 생성)하는데 트랜젝션을 시작하고, SQL을 실행하고, 커밋, 롤백, 트랜젝션 종료 등의 모든 요청은 DB 세션을 통해 실행이 된다. 

 

트랜젝션 내부 동작

 

데이터베이스는 커밋을 호출하기 전까지 데이터를 임시로 저장하는데 해당 트랜젝션을 시작한 세션만 변경 데이터(등록, 수정, 삭제)가 보이고 다른 세션은 변경중인 데이터가 보이지 않는데 이는 문제가 생겨서 트랜젝션 롤백이 되는 경우 데이터 정합성에 큰 문제가 생기기 때문이다.

 

트랜젝션에는 자동 커밋과 수동 커밋이 있는데 자동 커밋의 경우 말 그대로 쿼리를 하나 하나 실행할 때마다 자동으로 커밋을 해줘서 편리하지만 트랜젝션 개념이랑 맞지 않기 때문에 수동 커밋 모드(set autocommit false)로 바꾸어 사용하는 것이 트랜젝션의 시작이라고 볼 수 있다.

 

 

DB 락

 

한 세션에서 트랜젝션을 시작하고 데이터를 수정하는 동안 다른 세션이 같은 데이터를 수정하게 되면 트랜젝션의 원자성이 깨지는데 이런 문제를 방지하려면 트랜젝션이 커밋이나 롤백을 하기 전까지 다른 세션에서 해당 데이터를 수정할 수 없도록 막아야 한다.

 

데이터베이스는 이런 문제를 해결하기 위해 락(Lock)이라는 개념을 제공하는데 세션은 트랜젝션을 시작하고 값을 변경하려는 데이터의 row에 대해 먼저 lock을 얻는데 락을 갖고 있는 동안 다른 세션은 해당 row의 데이터를 변경할 수 없다. 락을 획득한 세션이 트랜젝션을 종료하면 락이 반납되고 다른 세션은 대기하다가 락을 획득한 뒤에 데이터를 변경할 수 있으며 락 대기 시간이 넘어가면 타임아웃 오류가 발생한다.

 

데이터베이스마다 다르지만, 락이 걸린 동안에는 다른 세션에서 데이터를 변경하지 못하고 지연이 걸리기 때문에 일반적인 데이터베이스는 조회를 할 때는 락을 사용하지 않고 바로 데이터를 조회할 수 있도록 한다.  조회를 할 때도 락이 필요한 경우  'select for update' 구문을 사용하면 되는데 조회를 통해 중요한 계산을 수행하는 경우 조회 시점에 락을 획득하면 된다.

 


 

트랜젝션 적용

 

트랜젝션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 문제가 되는 부분을 함께 롤백해야 하기 때문이다. 서비스 로직에서 트랜젝션을 시작하려면 먼저 커넥션을 획득하고 유지해야 하는데 애플리케이션에서 같은 커넥션을 유지하려면 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지해야 한다.

 

하지만 이러한 방법은 서비스 계층이 매우 복잡해지고 커넥션을 유지하기도 어려워지는데 스프링을 사용해서 트랜젝션을 편리하게 사용할 수 있다.

 

 

[참고]  인프런 김영한님 강의를 공부한 내용입니다.

 

커넥션 풀

 

커넥션을 획득할 때는 DB 드라이버에 커넥션 조회, TCP/IP 연결, 인증 정보 전달, 내부 인증, DB 세션 생성, 응답 결과, 커넥션 객체 생성 등 복잡한 과정을 거치는데 이는 클라이언트 응답 속도에 큰 영향을 준다. 이러한 문제를 해결하기 위해 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 방법이 있는데 애플리케이션 시작 시점에 일정 커넥션을 미리 확보해서 풀에 보관하는 것이다.

 

커넥션 풀에 보관중인 커넥션은 TCP/IP로 DB와 커넥션 연결이 되어 있는 상태로 애플리케이션은 커넥션 풀에 있는 커넥션을 조회해서 객체 참조로 가져다 사용하고 로직 종료 시 반환하는데 이때 커넥션은 살아있는 상태로 커넥션 풀에 반환해야 한다.

 

대표적인 커넥션 풀 오픈 소스로는 스프링 부트에서 기본으로 제공하는 HikariCP가 있다.

 

 


 

DataSource

 

이전 JDBC로 커넥션을 직접 획득할 때는 DriverManager를 통해 커넥션을 생성했는데 커넥션 풀을 사용할 경우 커넥션이 풀이 직접 커넥션을 생성한다. 문제는 DriverManager를 사용해서 커넥션을 획득하다가 커넥션 풀로 바꾸는 경우 의존 관계가 DriverManager에서 HikariCP로 바뀌기 때문에 애플리케이션 코드도 전부 바꿔야 되는 점이다.

 

자바에서는 이러한 문제를 해결하기 위해 DataSource 인터페이스를 제공하는데 DataSource는 커넥션을 획득하는 방법을 추상화한 인터페이스로 핵심 기능은 커넥션 조회 기능이다. DataSource를 생성하는 시점에 필요한 설정을 해두면 DataSource를 사용할 때는 getConnection() 만 호출하면 되기 때문에 설정과 사용을 분리할 수 있다.

 

HikariCP 커넥션 풀은 DataSource 인터페이스를 구현한 HikariDataSource를 사용하는데 커넥션 풀에 커넥션을 채우는 것은 상대적으로 시간이 오래 걸리기 때문에 HikariDataSource는 애플리케이션 실행 속도에 영향을 주지 않기 위해 별도의 쓰레드에서 커넥션을 생성한다. 

 

클라이언트가 데이터를 저장하거나 조회할 때, 애플리케이션 서버는 데이터베이스와 커넥션을 연결하고, SQL을 전달해서 결과를 응답 받는데 데이터베이스마다 이러한 방법이 모두 다르기 때문에 JDBC 라는 자바 표준이 등장했다.

 

JDBC는 자바에서 DB에 접속할 수 있도록 하는 자바 API연결(Connection), SQL 전달(Statement), 응답(ResultSet)의 인터페이스를 제공하는데 이 JDBC 인터페이스를 각각의 DB에 맞도록 구현한 라이브러리JDBC 드라이버라고 한다.

 

애플리케이션 로직이 JDBC 표준 인터페이스에 의존하게 되면서 데이터베이스를 변경해도 JDBC 구현 라이브러리만 변경하면 되는 등의 편의점이 생겼지만 JDBC 인터페이스로 공통화하는데 한계가 있어서 SQL은 각각의 데이터베이스에 맞게 변경을 해야하는 단점이 있다. (페이징 처리  SQL 등)

 

JDBC를 직접 사용하기 보다는 JDBC를 편리하게 사용하는 SQL Mapper와 ORM 기술이 있는데 이런 기술들도 내부적으로 JDBC를 사용하기 때문에 JDBC의 기본 동작 원리는 알고 있는 것이 좋다.

 

Connection

  • DriverManager : 라이브러리에 등록된 DB 드라이버를 관리하고, 커넥션을 획득하는 역할
  • getConnection(url, username, password) : 라이브러리에 있는 DB 드라이버를 찾아서 DB에 맞는 커넥션을 반환

 

Statement

  • connection.prepareStatement(sql) : 커넥션을 통해 DB에 전달할 SQL과 파라미터를 바인딩 (SQL Injection 방지)
  • statement.executeUpdate() : Statement를 통해 준비된 SQL을 실제 데이터베이스에 전달

 

ResultSet

  • statement.executeQuery() : 데이터를 조회하여 결과를 ResultSet에 담아서 반환한다.
  • resultSet.next() : resultSet은 반환받은 데이터를 커서로 가르키는데 next()로 커서를 이동할 수 있다.
  • resultSet.getXxx() : 현재 커서가 가르키는 데이터를 원하는 타입으로 변환해서 가져온다.

 

Connection을 생성하고 Statement를 통해 SQL을 실행하고 나면 리소스를 정리해야 되는데 역순으로 종료를 하면 된다. 리소스 정리를 하지 않으면 커넥션이 끊어지지 않고 계속 유지되어 리소스 누수가 발생하고 커넥션 부족으로 장애가 발생할 수 있다.

 

 

 

 

파일을 업로드하려면 바이너리 데이터를 전송해야 하는데 폼을 전송할 때는 파일만 전송하는 것이 아니라 문자와 바이너리 데이터를 동시에 전송하는 경우가 많다.

 

HTTP는 multipart/form-data 전송 방식으로 다른 종류의 여러 파일와 폼의 내용을 함께 전송할 수 있는데 서블릿 컨테이너는 멀티파트 데이터를 request 객체에 각각 나누어 담아 전송하고 request.getParts()로 받을 수 있다.

 

업로드 사이즈는 다음과 같이 제한할 수 있다.

spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB

// 옵션을 끄면 서블릿 컨테이너는 멀티파트 처리를 하지 않고 request.getParts()는 비어서 전송된다.
// Default true
spring.servlet.multipart.enabled=false

스프링의 DispatcherServlet에서 MultipartResolver가 실행이 되고 서블릿 컨테이너가 전달하는 HttpSerlvetRequest를 자식 인터페이스인 MultipartHttpServletRequest로 변환해서 반환해서 멀티파트와 관련된 추가 기능을 할 수 있게 한다.

 

서블릿이 제공하는 Parts는 멀티파트 형식을 편리하게 읽을 수 있는 다양한 메서드를 제공하지만 HttpServletRequest를 사용해야 하고 스프링이 제공하는 MultipartFile 인터페이스가 훨씬 편리하기 때문에 잘 사용하지 않는다.

 


 

스프링 MultipartFile

 

@RequestParam MultipartFile file, @ModelAttribute MultipartFile file 처럼 컨트롤러 파라미터에 입력만 해주면 사용할 수 있다.

  • getOriginalFilename() : 업로드 파일 명
  • transferTo() : 파일 저장

 

1. 업로드 파일 정보를 보관할 클래스

 

@Data
public class UploadFile {
    // 클라이언트에서 업로드한 파일명
    private String uploadFileName;
    // 서버에서 관리할 파일명 (중복되지 않을 이름으로 보관해야한다.)
    private String storeFileName;
    
    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}

 

2. 파일 저장 등을 처리하는 클래스

 

@Component
public class FileStore {
    @Value("${file.dir}")
    private String fileDir;
    
    public String getFullPath(String filename) {
        return fileDir + filename;
    }
    
    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
        List<UploadFile> storeFileResult = new ArrayList<>();
        
        for (MultipartFile multipartFile : multipartFiles) {
            if (!multipartFile.isEmpty()) {
                storeFileResult.add(storeFile(multipartFile));
            }
        }
        return storeFileResult;
     }
    
    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
         if (multipartFile.isEmpty()) {
             return null;
         }
         
         String originalFilename = multipartFile.getOriginalFilename();
         String storeFileName = createStoreFileName(originalFilename);
         multipartFile.transferTo(new File(getFullPath(storeFileName)));
         
         return new UploadFile(originalFilename, storeFileName);
    }
       
    private String createStoreFileName(String originalFilename) {
       String ext = extractExt(originalFilename);
       String uuid = UUID.randomUUID().toString();
       
       return uuid + "." + ext;
    }
       
    private String extractExt(String originalFilename) {
        int pos = originalFilename.lastIndexOf(".");
        
        return originalFilename.substring(pos + 1);
    }
}

 

설정 값 분리의 필요성 (@Value)

더보기

DB 연결 정보나 외부 API 주소, 파일 경로 같은 설정 값들을 클래스에 입력하는 대신 properties, yml 등으로 분리해서 관리하면 환경에 따라 유연하게 값을 설정해줄 수 있고 불필요한 컴파일, 직접 클래스 코드를 하나 하나 수정하는 등의 문제를 해결할 수 있다.

 

@Value

 

1. 프로퍼티 치환법

 

properties에 작성된 키 값을 ${}안에 넣어주면 Spring이 PropertyPlaceHolderConfigurer를 통해 초기화 작업 중에 해당 값을 실제 값으로 치환한다. PropertyPlaceHolderConfigurer는 팩토리 후처리기로써 빈 설정 메타정보가 모두 준비됐을 때 빈 메타정보 자체를 조작하는 역할을 한다. 하지만 대체할 위치를 치환자로 지정해두고 후처리기가 치환해주기를 기다리는 수동적인 방법으로 빈에서 직접 값을 꺼내는 SpEL 방법이 권장 된다.

 

2. SpEL (Spring Expression Language)

 

SpEL은 기본적으로 #{} 안에 표현식을 넣도록 되어있는데, user.name이라는 표현식은 이름이 user인 빈의 name 프로퍼티를 의미한다. SpEL은 다른 빈의 프로퍼티에 접근가능할 뿐만 아니라 메소드 호출도 가능하고 다양한 연산도 지원하며 클래스 정보에도 접근할 수 있다. 심지어 생성자를 호출해서 객체를 생성할 수도 있다. 

 

 

https://mangkyu.tistory.com/167

 

  • UploadFile : 클라이언트에서 업로드한 파일명과 서버에 저장할 파일명을 가지고 있는 클래스
  • List<UploadFile> storeFiles : multipartFile 을 하나씩 storeFile()을 통해 서버에 저장하고 List에 UploadFile을 add
  • UploadFile storeFile : UUID 랜덤값을 생성하고 확장자를 추출해서 서버 경로에 파일을 저장하고 UploadFile을 반환

 

3. 폼 전송 객체

 

@Data
public class ItemForm {
    private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles;
    private MultipartFile attachFile;
}

 

 

4. 컨트롤러

 

@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
    private final ItemRepository itemRepository;
    private final FileStore fileStore;
  
    @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }
      
    @PostMapping("/items/new")
    public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes
redirectAttributes) throws IOException {
        UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
        List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());

        //데이터베이스에 저장
        Item item = new Item();
        ...
        itemRepository.save(item);
        
        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:/items/{itemId}";
    }

    @GetMapping("/items/{id}")
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);
        return "item-view";
    }
    
    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws
MalformedURLException {
        return new UrlResource("file:" + fileStore.getFullPath(filename));
}

    @GetMapping("/attach/{itemId}")
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId)
throws MalformedURLException {
        Item item = itemRepository.findById(itemId);
        String storeFileName = item.getAttachFile().getStoreFileName();
        String uploadFileName = item.getAttachFile().getUploadFileName();
        UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));

        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";

        return ResponseEntity.ok()
                             .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                             .body(resource);
     }
}

 

  • Resource downloadImage : <img> 태그로 이미지를 조회할 때, UrlResource로 이미지 파일을 읽어서 이미지 바이너리를 반환
  • ResponseEntity<Resource> dounloadAttach : 파일을 다운로드 할 때 클라이언트가 업로드했던 파일명으로 생성

 

 

5. View

 

<form th:action method="post" enctype="multipart/form-data">
    <ul>
        <li>상품명 <input type="text" name="itemName"></li>
        <li>첨부파일<input type="file" name="attachFile" ></li>
        <li>이미지 파일들<input type="file" multiple="multiple" name="imageFiles" ></li>
    </ul>
    <input type="submit"/>
</form>
<div class="container">
    <div class="py-5 text-center">
    <h2>상품 조회</h2> </div>
    상품명: <span th:text="${item.itemName}">상품명</span><br/>
    첨부파일: <a th:if="${item.attachFile}" th:href="|/attach/${item.id}|"
th:text="${item.getAttachFile().getUploadFileName()}" /><br/>
    <img th:each="imageFile : ${item.imageFiles}" th:src="|/images/$
{imageFile.getStoreFileName()}|" width="300" height="300"/>

</div>

 

 

 

[참고] 인프런 김영한님 강의를 공부한 내용입니다.

+ Recent posts