게시판 만들기를 하면서 기계처럼 붙이고 보는 @Transactional을 왜 사용하는지 머릿속에 정리가 안 돼서 다시 강의를 보면서 정리를 해봤다.

 

데이터베이스를 사용하는 중요한 이유는 하나는 트랜잭션이라는 개념을 지원하기 때문인데 트랜잭션이란 하나의 논리적인 작업 단위로 계좌이체로 예를 들면 돈을 보내고 받는 과정이 하나의 작업이 되는 것이다. 작업이 성공적으로 끝나면 커밋(commit), 중간에 실패하면 되돌리는 롤백을 한다.

 

트랜잭션은 ACID를 보장해야 하는데 이 중에서 격리성(Isolation)은 동시성의 정도를 나타내는데 동시성을 보장하는 것은 성능과의 trade-off가 있어서 격리 수준이 나뉘게 된다.

 

 

 

[DB 접근 기술1] 트랜젝션(Transaction) 기초

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

treecode.tistory.com

 

[Real MySQL] MySQL의 격리 수준

트랜잭션의 격리 수준이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것이다. 격리 수준 READ U

treecode.tistory.com

 

트랜잭션을 사용하기 위해서는 auto commit을 false로 설정하면 되는데 일반적으로는 디폴트 값이 true로 되어 있다. 이렇게 되면 매번  쿼리마다 자동으로 커밋이 되다 보니 하나의 작업 단위를 묶을 수가 없어서 auto commit 옵션을 false로 주고 개발자가 직접 commit, rollback을 해야 된다.

 

커넥션 연결을 하게 되면 데이터베이스 내부에 세션이 생성되고 세션을 통해서 트랜잭션을 시작하고 SQL을 실행, 커밋, 롤백, 트랜잭션 종료와 같은 작업이 이루어진다.

하나의 논리적인 작업 단위의 기준은 비즈니스 로직으로 서비스 계층에서 시작을 해야 하는데 이렇게 되면 서비스 클래스에서 커넥션을 꺼내기 위해 DataSource를 가지고 트랜잭션 시작, commit, rollback, 트랜잭션 종료 등의 처리를 하는 코드가 들어가게 된다.

 

public void accountTransfer(String fromId, String toId, int money) throws
  SQLException {
	Connection con = dataSource.getConnection();
	try {
        con.setAutoCommit(false); //트랜잭션 시작 //비즈니스 로직
        bizLogic(con, fromId, toId, money); con.commit(); //성공시 커밋
    } catch (Exception e) { con.rollback(); //실패시 롤백
        throw new IllegalStateException(e);
    } finally {
        release(con);
    }
}

위의 예시에서 bizLogic()에서는 트랜잭션을 유지하기 위해 동일 커넥션을 사용해야 되고 repository를 호출할 때마다 커넥션을 파라미터로 전달해줘야 한다. 그리고 SQLException 같이 JDBC 전용 예외 같은 구체화에 의존성이 생기는 등 JdbcTempalte, JPA .. 데이터 접근 기술의 변경에 영향을 받게 된다.

 

스프링은 이러한 문제를 해결하기 위해 트랜잭션 추상화 인터페이스 TransactionManager를 제공하는데 트랜잭션 추상화, 커넥션 동기화 역할을 대신해 준다. 

 

 

커넥션은 트랜잭션 동기화 매니저가 스레드별 저장소인 스레드 로컬을 통해 동기화를 해서 파라미터로 전달하지 않아도 유지할 수 있다. 스레드 로컬은 사용 후 정리하는 것이 매우 중요하기 때문에 (+ 커넥션 반납 등) 개발자가 직접 하기보다는 제공되는 기술을 사용하는 것이 안전하다.

 

트랜잭션 매니저를 사용해도 커밋, 롤백 try-catch를 하는 코드가 남아있는데 스프링 AOP를 활용한 @Transactional을 사용하면 깔끔하게 처리가 가능해진다.

 

@Transactional을 정확히 이해하려면 스프링 AOP를 학습해야 되는데 일단은 왜 써야 하는지 정도만 알고 넘어갔다.

@Transactional을 클래스에 메서드에 사용하면 트랜잭션 AOP는 프록시를 만들어 스프링 컨테이너에 등록하는데 이 프록시가 트랜잭션을 관리하는 로직을 대신 처리해 주게 된다. 

 

한 가지 주의점은 외부에서 요청을 하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 실제 메서드를 호출해 주는데 만약 내부에서 메서드를 호출하는 경우 프록시를 거치지 않아서 트랜잭션이 적용되지 않는 문제가 생긴다.

이전에는 클래스에 @Transactional을 readOnly 설정으로 두고 데이터 삽입, 수정, 삭제 메서드만 따로 @Transacional을 선언해서 사용을 해서 그런지 이런 위험 요소를 인식하지 못했다.

 

클래스에 @Transactional을 선언하는 경우 트랜잭션이 의도하지 않은 곳까지 과도하게 적용이 돼서 메서드 단위로 적용을 한다. 트랜잭션은 주로 비즈니스 로직의 시작점에 걸기 때문에 외부에서 열어둔 곳을 시작점으로 해서 클래스에 @Transactional 애노테이션을 선언해도 public 메서드에만 적용이 된다.

 

 

추가적으로 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백하는데 RuntimeException , Error와 그 하위 예외가 발생하면 롤백, 체크 예외와 그 하위 예외가 발생하면 커밋을 한다. (@Transactional에서 rollbackFor 옵션으로 변경 가능)

체크 예외의 경우 비즈니스적인 예외 상황에서 주로 사용한다고 하는데 예를 들면 회원의 주문 Order에서 잔고 부족의 경우 일단 커밋을 하고 추가 결제를 하도록 할 수도 있다. (생각해 보니 체크 예외는 사용한 적이 없는 거 같은데 중요한 비즈니스 예외 상황에서 사용하면 좋을 것 같다.)

 

간단 정리

@Transactional 사용하는 이유

 

- 애플리케이션에서 트랜잭션 관리

- 비즈니스 로직과 관련 없는 중복 코드 제거

- 예외 전파, 관리

 

JdbcTemplate은 템플릿 콜백 패턴을 사용해서 JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업을 대신 처리해 준다.

 

- 커넥션 획득, 종료, statement, resultset 종료

- 트랜잭션을 다루기 위한 커넥션 동기화

- 예외 발생 시 스프링 예외로 변환

 

JdbcTemplate에서 tryForStream() 메서드를 사용하면 예외적으로 직접 리소스 종료를 해줘야 하는데 그렇지 않을 경우 커넥션 반납이 되지 않는 문제가 생긴다. 같이 공부하는 분이 커넥션 반납이 되지 않는다고 물어보셨을 때 @Transactional 역할을 잘 알고 있었으면 이 부분을 먼저 확인해 봤을 거 같은데 금붕어처럼 다 까먹고 말똥말똥 쳐다보고 있었다. @Transactional을 사용하면 트랜잭션 기능 외에도 DB 접근 기술마다 커넥션이 종료되지 않는 상황에서 안전하게 처리를 해주는 이점도 있는 것 같다.

 

 

[참고]

인프런 김영한님 DB 접근 기술 1,2편

Real MySQL 단톡방에서 어떤 분이 페이징을 할 때 order by에 대해 질문을 하셨는데 저자분께서 친절하게 답변을 해주셨다. 마침 코드스쿼드에서 미션으로 간단한 게시판 구현을 해보고 있어서 LIMIT 옵션에 대해서 공부를 해보았다.

Real MySQL 11장 LIMIT

더보기

MySQL의 LIMIT는 항상 쿼리의 가장 마지막에 실행되고 LIMIT에서 필요한 레코드 건수만 준비되면 즉시 쿼리를 종료한다.

 

ORDER BY나 GROUP BT, DISTINCT가 인덱스를 이용해 처리될 수 있다면 LIMIT 절은 꼭 필요한 만큼의 레코드만 읽게 만들어주기 때문에 쿼리의 작업량을 상당히 줄여준다.

 

LIMIT는 1개 또는 2개의 인자를 사용할 수 있는데 첫번째 인자에 지정된 위치부터 두번째 인자에 명시된 개수만큼의 레코드를 가져온다.

 

즉 LIMIT(시작 위치, 오프셋)으로 LIMIT 10과 같이 인자가 1개인 경우는 LIMIT 0, 10과 동일하다.

 

참고로 LIMIT의 인자로 표현식이나 별도의 서브쿼리는 사용할 수 없다.

 

LIMIT 쿼리에서 주의할 점은 몇 건을 읽을건지보다 그 결과를 만들어 내기 위해 어떤 작업을 했는지다.

 

SELECT * FROM salaries ORDER BY salary LIMIT 2000000, 10;

 

위의 쿼리는 salaries 테이블을 처음부터 읽으면서 2000010건의 레코드를 읽어서 2000000건은 버리고 마지막 10건만 반환한다. 그래서 이런 경우 WHERE 조건으로 읽어야 할 위치를 찾고 그 위치에서 10개만 읽는 형태의 쿼리를 사용하는 것이 좋다.

 

댓글 리스트 조회시 Request Parameter로 startId와 읽어올 개수 size를 전달 받는다. 더보기 버튼을 누르면 다음 댓글들을 더 보여주도록 구현을 했다.

 

이전에 LIMIT 조건의 주의점을 보긴 했었는데 까먹고 있다가 다시 책을 보면서 아차 싶었다. LIMIT에 시작 위치를 주는 경우 그 값이 점점 커진다면 성능이 저하될 수 있으니 WHERE 조건을 잘 활용하도록 주의해야겠다. (물론 지금은 전체 게시글이 아닌 특정 게시글의 댓글을 조회하는 것이기 때문에 큰 문제는 없을 것 같다.)

+ ORDER BY가 인덱스를 이용해 처리될 수 있으면 LIMIT 시 쿼리의 작업량을 많이 줄여준다.

 

 

더보기 버튼을 누르는 식으로 구현을 했기 때문에 댓글 리스트를 읽어올 때 전체 count를 불필요하게 읽을 필요가 없다. 대신 다음 댓글이 있는지 여부를 확인하기 위해 size + 1만큼 읽어온다. Dto로 변환할 때는 subList()를 사용해서 size 개수(마지막 페이지의 경우 size 이하의 개수)만큼 잘라주었다. (급하게 구현해서 네이밍이 맘에 안 든다..)

 

 

응답 결과는 다음처럼 댓글 리스트와 다음 댓글 여부(hasNext)를 반환해준다.

 

커서 기반 페이징이 간단한 것 같았는데 아래 글을 보니 복잡한 경우에서는 고려해야 될 부분이 많은 것 같다.. 갈 길이 멀다.

 

커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기

사실 처음에는 이 주제로 포스트를 쓰려고 했던건 아니고 Apollo GraphQL 에서 커서 기반 페이지네이션 구현 을 주제로 글을 쓰려고 했습니다. 그런데 막상 찾아보니 백엔드-프론트엔드를 함께 고려

velog.io

 

 

 

 

[참고]

Real MySQL 8.0

 

HttpSession

 

The servlet container uses this interface to create a session between an HTTP client and an HTTP server. The session persists for a specified time period, across more than one connection or page request from the user.

 

서블릿 컨테이너는 javax.servlet.http의 HttpSession 인터페이스를 사용하여 세션 기능을 제공하는데 일반적으로 HttpServletRequest의 getSession()을 호출하면 세션을 가져오거나 옵션에 따라 없을 경우 생성해서 반환해준다. 

 

서블릿 컨테이너의 세션 저장소는 Map 형태로 되어 있는데 HttpSession 인스턴스가 value에 저장이 되고 httpSession 또한 내부적으로 Map 형태로 생성이 된다. 그래서 setAttribute()로 로그인 정보를 저장할 수 있다.

 

 

로그아웃을 할 때 로그인 세션 정보를 제거하기 위한 방법으로는 HttpSession의 removeAttribute()와 invalidate() 메서드가 있다,

 

 

invalidate()는 세션 자체를 무효화하고 제거하고 removeAttribute()는 현재 세션에서 특정 key-value만 제거를 한다.

removeAttribute()로 키만 제거를 하면 httpSession 인스턴스는 WAS의 세션 저장소에 남아있어서 invalidate()를 사용하는 것이 좋다.

 

 

 

[참고]

 

 

HttpSession (Java(TM) EE 7 Specification APIs)

Provides a way to identify a user across more than one page request or visit to a Web site and to store information about that user. The servlet container uses this interface to create a session between an HTTP client and an HTTP server. The session persis

docs.oracle.com

 

HttpSession은 어떻게 만들어지고 어떻게 유지될까(feat. 코드를 통해 확인하는 JSESSION의 생성 방법과

서론 클라이언트와 서버는 Stateless인 HTTP 통신을 하게 되지만 로그인과 같이 접속을 했던 정보가 저장이 되어야 할때가 있다. 이때 인증과 인가가 필요하게 된다. 인증은 클라이언트에서 보낸 정

oh-sh-2134.tistory.com

 

 

개념

- 디스크 읽기 방식

- 인덱스란?

- B-Tree 인덱스

- 클러스터링 인덱스

- 유니크 인덱스

- 외래키

 

 

디스크 읽기 방식


 

디스크를 읽는 방식에는 랜덤(Random) I/O, 순차(Sequential) I/O가 있다. 이전에 가상 메모리, 페이징을 공부하면서 디스크 I/O를 줄이는 것이 중요하다는 것을 배웠는데 일반적으로 데이터베이스도 디스크에 데이터를 읽고 저장하기 때문에 디스크 I/O가 성능에 큰 영향을 끼친다.

 

순차 I/O는 디스크 헤더를 움직이지 않고 한번에 많은 데이터를 읽는 작업으로 비중이 크지 않다.

랜덤 I/O는 부분적으로 작은 데이터를 읽고 쓰는 작업으로 대부분의 작업이 이에 해당한다.

 

디스크 원판을 가지지 않는 SSD는 HDD에 비해 랜덤 I/O 속도가 훨씬 빠른데 그래도 순차 I/O에 비하면 전체 스루풋(Throughput)이 떨어진다고 한다.

 

일반적으로 쿼리를 튜닝하는 것은 랜덤 I/O 자체를 줄여주는 것이 목적으로 쿼리를 처리하는 데 꼭 필요한 데이터만 읽도록 쿼리를 개선하는 것을 의미한다.

 

 

인덱스란?


 

인덱스에 대해 찾아보면 대부분 책 뒤에 있는 색인으로 비유를 하는데 "ㄱ", "ㄴ", "ㄷ" 와 같은 순서로 정렬을 해서 원하는 데이터를 빠르게 찾을 수 있도록 도와주는 역할을 한다. 데이터의 저장(INSERT, UPDATE, DELETE) 성능을 희생하고 읽기 속도를 향상 시키는 것이다.

 

인덱스는 유니크(Unique)한 인덱스와 유니크 하지 않은 인덱스로 구분할 수 있는데 이는 실제 쿼리를 실행해야 하는 옵티마이저에게 상당히 중요한 문제이다. 유니크 인덱스에 대해 동등 조건('=')으로 검색한다는 것은 항상 1건의 코드만 찾으면 더 찾지 않아도 된다는 것을 옵티마이저에게 알려주는 효과를 낸다.

 

 

B-Tree 인덱스


 

인덱싱 알고리즘으로 가장 일반적으로 사용되는 B-Tree(Balanced-Tree)는 다음과 같은 구조로 되어 있다.

 

- 최상위 루트 노드(Root node)

- 중간 자식 노드 (Branch node)

- 최하위 자식 노드 (Leaf node)

 

데이터베이스에서 인덱스와 실제 데이터가 저장된 데이터는 따로 관리되는데 일반적으로 인덱스의 리프 노드는 실제 데이터 레코드를 찾아가기 위한 주솟값을 가지고 있다.

 

데이터 파일은 일반적으로 정렬이 되지 않은 상태로 저장이 되는데 DELETE 된 공간이 다시 재활용 되기 때문에 순서가 섞인다.

 

InnoDB 테이블

- 레코드가 클러스터 되어 디스크에 저장 되기 때문에 기본적으로 프라이머리 키 순서로 정렬이 되어 저장

- 리프 노드가 실제 주솟값이 아닌 프라이머리 키를 논리적인 주소로 사용 (세컨더리 인덱스 검색에서 프라이머리 키를 저장하고 있는 B-Tree를 다시 한번 검색)

 

인덱스 키를 추가할때 리프 노드가 꽉차면 분리가 되면서 상위 브랜치 노드까지 처리의 범위가 넓어지는데 이러한 이유로 B-Tree는 상대적으로 쓰기 작업에 비용이 많이 든다. (이 비용의 대부분이 메모리와 CPU에서 처리하는 시간이 아니라 디스크로부터 인덱스 페이지를 읽고 쓰기를 해야 해서 걸리는 시간)

 

아무튼 이런 비용을 감당하면서 인덱스를 사용하는 이유는 빠른 검색을 위해서이다. DB을 잘 사용하려면 얻는 것과 잃는 것에 대해 많은 경험, 고민이 필요한 것 같다.

 

인덱스를 이용한 검색에서 주의할 점은 인덱스의 키 값에 변형이 가해진 후 비교되는 경우 B-Tree의 빠른 검색 기능을 사용할 수 없다는 것인데 함수나 연산을 수행한 결과로 정렬, 검색을 하는 작업은 B-Tree의 장점을 이용할 수 없으므로 주의해야 한다.

 

InnoDB 스토리지 엔진은 디스크에 데이터를 저장하는 가장 기본 단위를 페이지 또는 블록이라고 하며 디스크의 모든 읽기 및 쓰기 작업의 최소 작업 단위가 된다. 위의 루트, 브랜치, 리프 노드를 구분한 기준도 페이지 단위이다.

하나의 인덱스 페이지는 4KB ~ 64KB(기본값 16KB)의 크기를 가지는데 인덱스 키 값이 커지면 그만큼 하나의 페이지에 담을 수 있는 키가 줄어들고 페이지 깊이(depth)가 증가하게 된다. 이는 디스크로부터 읽어야 하는 횟수가 늘어나고 그만큼 느려질 수 있다는 것을 의미한다.

 

인덱스를 통해 테이블의 레코드를 일는 것은 인덱스를 거치지 않고 바로 테이블의 레코드를 읽는 것보다 높은 비용이 드는 작업인데 인덱스를 통해 읽어야 할 레코드의 건수가 많다면(대략 전체 테이블 레코드의 20~25% 기준) 인덱스를 사용하지 않고 테이블을 모두 읽어서 필터링 하는 방식이 효율적이다. (MySQL의 옵티마이저는 이런 경우 예상을 해서 효율적인 방식으로 처리한다)

 

 

클러스터링 인덱스


 

MySQL 서버에서 클러스터링은 테이블의 레코드를 비슷한 것(프라이머리 키)들끼리 묶어서 저장하는 형태로 구현되는데 InnoDB는 프라이머리 키를 클러스터링 인덱스로 사용한다. 중요한 것은 프라이머리 키 값에 의해 레코드의 저장 위치가 결정된다는 것이다.

 

프라이머리 키가 세컨더리 인덱스에 미치는 영향

 

InnoDB 테이블의 모든 세컨더리 인덱스는 해당 레코드가 저장된 주소가 아니라 프라이머리 키(클러스터링 인덱스) 값을 저장하도록 구현이 되어 있다. 

 

 

세컨더리 인덱스가 프라이머리 키 값을 포함하고 있기 때문에 프라이머리 키의 크기가 커질 경우 세컨더리 인덱스도 자동으로 크기가 커지게 되서 위에서 말한대로 디스크 I/O가 증가하게 된다. 그래서 프라이머리 키의 크기는 가능하면 작게 사용하는 것이 효율적이다.

하지만 InnoDB의 프라이머리 키는 클러스터링 키로 사용되며 검색에 큰 이점이 있어서 해당 칼럼의 크기가 크더라도 업무적으로 해당 레코드를 대표할 수 있다면 그 칼럼을 프라이머리 키로 설정하는 것도 좋다고 한다.

 

 

유니크 인덱스


 

유니크 인덱스는 고유한 값으로 검색 시 1건만 찾으면 되기 때문에 유니크하지 않은 인덱스에 비해 훨씬 빠를 것 같지만 유니크하지 않은 인덱스의 경우 CPU에서 칼럼값을 비교하는 작업이 추가되는 것이라서 성능상 큰 차이는 없다고 한다.

오히려 유니크 인덱스는 주의해야 될 점이 있는데 유니크 인덱스 쓰기 작업의 경우 별도로 중복된 값이 있는지 없는지 체크를 한다. MySQL에서는 중복된 값을 체크할 때 읽기 잠금을 사용하고, 쓰기를 할 때는 쓰기 잠금을 사용하는데 이 과정에서 데드락이 빈번히 발생한다. 그리고 InnoDB 스토리지 엔진은 인덱스 키의 저장을 체인지 버퍼(Change Buffer)를 사용해서 버퍼링을 하기 때문에 인덱스의 쓰기 작업을 빨리 처리할 수 있는데 유니크 인덱스의 경우는 중복 체크를 해야 하기 때문에 작업 자체를 버퍼링하지 못한다. (= 꼭 필요할때 사용)

 

 

외래키


 

InnoDB의 외래키 관리

 

- 테이블의 변경(쓰기 잠금)이 발생하는 경우에만 잠금 경합(잠금 대기)이 발생한다.

- 외래키와 연관되지 않은 칼럼의 변경은 최대한 잠금 경합(잠금 대기)를 발생시키지 않는다.

 

자식 테이블의 외래 키 칼럼의 변경은 부모 테이블의 확인이 필요한데, 이 상태에서 부모 테이블의 해당 레코드가 쓰기 잠금이 걸려 있으면 해당 쓰기 잠금이 해제될 때까지 기다리게 된다. 자식 테이블의 외래키가 아닌 칼럼의 변경은 외래키로 인한 잠금 확장이 발생하지 않는다.

 

데이터베이스에서 외래 키를 물리적으로 생성하려면 이러한 잠금 경합을 고려해 모델링을 해야한다. 물리적으로 외래키를 생성하면 자식 테이블에 레코드가 추가되는 경우 해당 참조키가 부모 테이블에 있는지 확인하는데 이때 연관 테이블에 읽기 잠금을 걸어서 이런 잠금 확장으로 인한 영항을 신경 써야한다.

 

 

 

[참고]

Real MySQL 책

+ Recent posts