트랜잭션 2

2025. 10. 13. 00:27·Spring

문제점

이전 시간에 배운대로 트랜잭션을 사용하면 문제가 있다.

 

1. 트랜잭션 문제

- 트랜잭션을 관리하려면 Connection, SQLException, commit, rollback 같은 JDBC 코드 필요

- 즉 서비스 계층이 JDBC 기술에 종속됨 -> 순수하지 않은 서비스 계층

- 추후 DB 관련 기술을 바꾸면 내부 코드까지 전부 변경해야함.

 

2. 예외 누수

- JDBC의 SQLException이 체크 예외라 서비스 계층까지 퍼짐

- 서비스는 비즈니스 로직만 알아야하는데 DB예외까지 알아야하는 경우 발생

 

3. JDBC 반복 

- try-catch-finally, 커넥션 열기/닫기, PreparedStatement 설정을 반복적으로 작성하게 됨

- 결국 비즈니스 로직보다 이런 부가적인 코드에 시간을 낭비하게 됨

 

트랜잭션 추상화

현재 서비스 계층은 트랜잭션을 사용하기 위해 JDBC 기술에 의존하고 있다. 향후 JPA로 변경한다면 서비스 계층의 트랜잭션 관련 로직을 모두 수정해야한다.

 

이 문제를 해결하려면 트랜잭션 기능을 추상화하면 된다.

트랜잭션은 단순하다. 트랜잭션을 시작 -> 비즈니스 로직 수행 -> 끝나면 커밋or롤백

 

 

스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다. 데이터 접근 기술에 따른 트랜잭션 구현체도 대부분 만들어져있다.

 

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.transaction;

import org.springframework.lang.Nullable;

public interface PlatformTransactionManager extends TransactionManager {
    TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;

    void commit(TransactionStatus status) throws TransactionException;

    void rollback(TransactionStatus status) throws TransactionException;
}

 

getTransaction() : 트랜잭션을 사용한다. get인 이유는 기존에 진행중인 트랜잭션이 있는 경우 해당 트랜잭션에 참여할 수 있기 때문이다. 참여, 전파에 대한 부분은 뒤에서 설명. 지금은 그냥 시작한다정도만 알고 있으면 된다.

 

트랜잭션 동기화

트랜잭션을 유지하려면 시작부터 끝까지 같은 커넥션을 유지해야한다. 결국 커넥션을 동기화(맞춰서 사용)하기 위해서는 이전엔 파라미터로 커넥션을 전달했는데, 이 방법은 코드가 지저분하고, 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 두가지 만들었던 기억을 되살려보면 중복 코드가 발생하고 여러 단점이 있다.

 

스프링은 트랜잭션 동기화 매니저를 제공한다. 이것은 쓰레드 로컬을 사용해서 커넥션을 동기화 해준다.

(쓰레드 로컬은 스프링 핵심원리 고급편에서 다룬다.)

이걸 사용하면 파라미터로 커넥션을 전달하지 않아도 멀티쓰레드 상황에서 안전하게 커넥션을 동기화 할 수 있다.

 

간단한 동작 방식 설명

1. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작

2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관

3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용

4. 트랜잭션 종료시 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션 종료하고 커넥션 닫음

 

트랜잭션 문제 해결 - 트랜잭션 매니저1

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

// 트랜잭션 - 트랜잭션 매니저
// DataSourceUtils.getConnection()
// DataSourceUtils.releaseConnection()

@Slf4j
public class MemberRepositoryV3 {

    private final DataSource dataSource;

    public MemberRepositoryV3(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values (?, ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet resultSet = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            ResultSet rs = pstmt.executeQuery();
            if (rs.next()) {
                Member member = new Member();
                member.setMemberId(rs.getString("member_id"));
                member.setMoney(rs.getInt("money"));
                return member;
            } else {
                throw new NoSuchElementException("member not found memberId=" + memberId);
            }
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, resultSet);
        }
    }

    public void update(String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id=?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            int resultSize = pstmt.executeUpdate();
            log.info("resultSize={}", resultSize);
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id=?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    private void close(Connection connection, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        // 주의 트랜잭션 동기화 사용하려면 DataSourceUtils 사용
        DataSourceUtils.releaseConnection(connection, dataSource);
    }

    private Connection getConnection() throws SQLException {
        // 주의. 트랜잭션 동기화 사용하려면 DataSourceUtils 사용
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }
}

커넥션을 파라미터로 전달하는 부분을 제거

 

getConnection() : 트랜잭션 동기화를 사용하기 위해 DataSourceUtils를 사용했다. 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다. 없다면 새로 생성한다.

 

close() : 커넥션을 con.close()로 닫아버리면 커넥션이 유지되지 않기 때문에 DataSourceUtils.releaseConnection()를 사용했다.

커넥션을 바로 닫는것이 아닌 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지않고 그대로 유지해주고, 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.

 

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

// 트랜잭션 - 파라미터 연동, 풀을 고려한 종료
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {

    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        // 트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            //비즈니스 로직 수행
            bizLogic(fromId, toId, money);

            transactionManager.commit(status); // 성공시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); // 실패시 롤백
            throw new IllegalStateException(e);
        }
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepository.update(toId, toMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }

    private static void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true); // 커넥션 풀 고려해서 true
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }
}

 

private final PlatformTransactionManager transactionManager

- 트랜잭션 매니저를 주입 받는다. 지금은 JDBC 기술을 사용하기 때문에 DataSourceTransactionManager를 주입받았다.

(후에 JPA같은 기술로 변경된다면 JpaTransacitonManager를 주입받으면 됨)

 

transactionManager.getTransaction() : 트랜잭션 시작. TransactionStatus status를 반환한다. 여기에 트랜잭션 상태 정보가 포함되어있다. 이후 트랜잭션을 커밋, 롤백할 때 필요

 

new DefaultTransactionDefinition() : 트랜잭션 관련 옵션 설정가능. 자세한 내용은 이후 설명

 

// 트랜잭션 매니저
class MemberServiceV3_1Test {

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    private MemberRepositoryV3 memberRepository;
    private MemberServiceV3_1 memberService;

    @BeforeEach
    void before() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV3(dataSource);

        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);

        memberService = new MemberServiceV3_1(transactionManager, memberRepository);
    }
    이하 생략

 

new DataSourceTransactionManager(dataSource)

- JDBC 기술을 사용하므로 JDBC용 트랜잭션 매니저를 선택해서 서비스에 주입한다.

- 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하므로 DataSource가 필요하다.

 

트랜잭션 문제해결 - 트랜잭션 매니저2

트랜잭션 매니저의 전체 동작 흐름을 정리해보자

 

1. 트랜잭션 시작 - 서비스 계층에서 transactionManager.getTransaction() 호출

2. 커넥션 생성 - 데이터베이스 커넥션이 필요하기 때문에 트랜잭션 매니저는 내부에서 dataSource를 사용해 커넥션 생성

3. 트랜잭션 시작 - 커넥션을 수동 커밋 모드로 변경

4. 커넥션을 트랜잭션 동기화 매니저에 보관

5. 트랜잭션 동기화 매니저에 커넥션 보관 - 쓰레드 로컬에 커넥션을 보관해서 멀티 쓰레드 환경에서도 안전하게 커넥션 보관 가능

6. 서비스에서 리포지토리 메서드 호출 - 이 때 커넥션을 파라미터로 전달하지 않음

7. 트랜잭션 동기화 - 리포지토리의 메서드는 트랜잭션이 시작된 커넥션이 필요. 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용 (DataSourceUtils.getConnection())

8. SQL 실행 - 획득한 커넥션을 사용해서 DB에 SQL 전달

 

트랜잭션 문제해결 - 트랜잭션 템플릿

코드를 보면 반복되는 문제가 있다.

try-catch-finally를 사용해서 성공하면 커밋, 실패하면 롤백 이런식으로 코드를 작성했는데, 이 부분을 더 리팩토링해보자

 

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.transaction.support;

import java.util.function.Consumer;
import org.springframework.lang.Nullable;
import org.springframework.transaction.TransactionException;
import org.springframework.transaction.TransactionStatus;

public interface TransactionOperations {
    @Nullable
    <T> T execute(TransactionCallback<T> action) throws TransactionException;

    default void executeWithoutResult(Consumer<TransactionStatus> action) throws TransactionException {
        this.execute((status) -> {
            action.accept(status);
            return null;
        });
    }

    static TransactionOperations withoutTransaction() {
        return WithoutTransactionOperations.INSTANCE;
    }
}

 

(TransactionTemplate은 TransactionOperations를 상속받는다)

 

execute() : 응답값이 있을 때 사용

executeWithoutResult() : 응답값이 없을 때 사용

public class MemberServiceV3_2 {

    private final TransactionTemplate txTemplate;
    private final MemberRepositoryV3 memberRepository;

    public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
        this.txTemplate = new TransactionTemplate(transactionManager);
        this.memberRepository = memberRepository;
    }

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        txTemplate.executeWithoutResult((status) -> {
            try {
                bizLogic(fromId, toId, money);
            } catch (SQLException e) {
                throw new IllegalStateException();
            }
        });
    }
이하생략

 

bizlogic이 void기 때문에 executeWIthoutResult를 사용했다.

 

이렇게 트랜잭션 템플릿을 사용하면 커밋, 롤백하는 로직을 따로 작성하지 않아도 내부에서 처리해준다.

비즈니스 로직이 정상 수행되면 커밋, 언체크 예외 발생시 롤백 (체크 예외시 커밋한다)

예외 처리를 위해 try-catch가 들어갔는데, 위 코드 구조 상 해당 람다에서 체크 예외를 밖으로 던질 수 없기 때문에 언체크로 던지도록 예외를 전환했다. 크게 신경쓰지말기

 

결과적으로 트랜잭션을 사용하는 반복 코드를 제거했다. 그러나 서비스 로직에 아직도 트랜잭션 처리하는 로직이 들어가 있다.

나중에 트랜잭션을 안쓰게 된다면 서비스 코드를 또 수정해야되는 문제가 생긴다.

 

트랜잭션 문제 해결 - 트랜잭션 AOP 

지금까지 트랜잭션을 편리하게 사용하기 위해 추상화 도입, 반복적인 로직 작성을 해결하기 위해 트랜잭션 템플릿도 도입했다.

그러나 아직 서비스 계층에 순수 비즈니스 로직만 남기는것은 해결하지 못했다.

이럴 때 스프링 AOP를 통해 프록시를 도입하면 문제를 해결할 수 있다.

 

(현재는 @Transactional 을 사용해서 간편하게 해결할 수 있다 정도만 이해해도 됨)

기존에는 서비스 로직에서 트랜잭션을 직접 시작한다.

transactionManager.getTransaction() -> try {bizlogic() ... ... commit } catch { .... }

 

 

프록시를 사용하면 트랜잭션을 처리하는 개체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.

 

(예시코드)

public class TransactionProxy {
    private MemberService target;
    public void logic() {
        //트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(..);
        try {
        //실제 대상 호출
            target.logic();
            transactionManager.commit(status); //성공시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); //실패시 롤백
            throw new IllegalStateException(e);
        }
    }
}
public class Service {
    public void logic() {
        //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
        bizLogic(fromId, toId, money);
    }
}

 

도입 후엔 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져가므로 서비스 계층에는 순수한 서비스 로직만 남게 된다.

 

트랜잭션 AOP에 자세한 내용은 이후에 공부하고, 지금은 @Transactional을 사용하면 어떻게 되는지만 알면된다.

 

트랜잭션 문제 해결 - 트랜잭션 AOP 적용

트랜잭션 AOP를 사용하는 새로운 서비스 클래스

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.sql.Connection;
import java.sql.SQLException;

// 트랜잭션 - @Transactional AOP
@Slf4j
public class MemberServiceV3_3 {

    //    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 memberRepository;

    public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        bizLogic(fromId, toId, money);
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepository.update(toId, toMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

transactionTemplate은 이제 필요없다. @Transactional 어노테이션을 붙이고, 비즈니스 로직만 남게 되었다.

 

package hello.jdbc.service;

// 트랜잭션 - @Transactional AOP
@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    @Autowired
    private MemberRepositoryV3 memberRepository;
    @Autowired
    private MemberServiceV3_3 memberService;

    @TestConfiguration
    static class TestConfig {
        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }

        @Bean
        PlatformTransactionManager transactionManager() {
            return new DataSourceTransactionManager(dataSource());
        }

        @Bean
        MemberRepositoryV3 memberRepositoryV3() {
            return new MemberRepositoryV3(dataSource());
        }

        @Bean
        MemberServiceV3_3 memberServiceV3_3() {
            return new MemberServiceV3_3(memberRepositoryV3());
        }
    }

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    void AopCheck() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberRepository class ={}", memberRepository.getClass());
        Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        //when
        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);

        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        //when
        assertThatThrownBy(() -> memberService.accountTransfer(
                memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

 

현재 프로젝트는 순수 JDBC를 사용했기 때문에 @Transactional을 사용하려면 스프링 컨테이너가 필요한데, 환경에서 테스트를 해야하기 때문에 @SpringbootTest 어노테이션을 사용했다.

@TestConfiguration : 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 어노테이션을 붙이면 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.

*스프링 부트는 DataSource를 자동으로 빈 등록을 해준다. 트랜잭션 매니저는 application.properties에 지정된 속성을 보고 적절한 트랜잭션 매니저를 자동으로 생성해준다. 생성자를 통해서 데이터소스 빈을 주입받을 수도 있다. 

(private final Datasource datasource;)

 

내가 이해한 내용으로 정리

 

결국 서비스 계층에서 try-catch를 안쓸 수 없다. 반드시 써야한다. 그런데 관심사 분리를 하려면, 결국 서비스 계층을 거쳐서 트랜잭션 로직을 수행해야 하는데, 메인 서비스 계층 말고 가상의 서비스 계층에서 try-catch를 대신 수행하고 결과 값만 진짜 서비스로 전달하는 것

 

서비스 계층에 try-catch를 쓰면 관심사 분리가 깨지니까 트랜잭션 같은 공통 로직을 대신 수행할 가짜 서비스 (프록시)를 하나 더 둔다.

 

트랜잭션 AOP 정리

*의존관계 주입받은건 진짜 서비스가 주입된게 아니고 AOP Proxy가 주입된 것

 

'Spring' 카테고리의 다른 글

스프링과 문제 해결 - 예외처리, 반복  (0) 2025.10.17
자바 예외 이해  (0) 2025.10.16
트랜잭션  (0) 2025.10.02
커넥션 풀과 데이터소스  (0) 2025.09.29
JDBC  (0) 2025.09.28
'Spring' 카테고리의 다른 글
  • 스프링과 문제 해결 - 예외처리, 반복
  • 자바 예외 이해
  • 트랜잭션
  • 커넥션 풀과 데이터소스
공부처음하는사람
공부처음하는사람
  • 공부처음하는사람
    lazzzykim
    공부처음하는사람
  • 전체
    오늘
    어제
    • 분류 전체보기 (159)
      • Kotlin (31)
      • Java (56)
      • Spring (44)
      • JPA (6)
      • Algorithm (3)
      • TroubleShooting (1)
      • 내일배움캠프 프로젝트 (14)
      • Setting (2)
      • ... (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
  • 링크

  • 인기 글

  • 태그

    배열
    kotlin
    빈 생명주기
    jpa
    김영한의 실전 자바
    spring
    내일배움캠프
    OCP
    김영한의 실전자바
    다형성
    싱글톤
    김영한
    Di
    언체크예외
    캡슐화
    중첩클래스
    제네릭
    java
    예외처리
    트랜잭션
  • hELLO· Designed By정상우.v4.10.3
공부처음하는사람
트랜잭션 2
상단으로

티스토리툴바