/ #JAVA#SPRING

스프링 프레임워크(Spring Framework)

스프링(Spring)

스프링(Spring)은 자바 엔터프라이즈 개발을 위한 오픈소스 경량 애플리케이션 프레임워크 입니다. 파이썬에는 Django, 자바스크립트에는 Nodejs를 통해 웹서버를 개발한다면, 자바에서는 Spring을 사용하여 웹서비스를 만들 수 있습니다. 우리나라에서는 공공기관의 웹 서비스 개발 시 사용하는 전자정부 표준프레임워크의 기반 기술로 스프링을 사용하고 있습니다.

스프링 이전에는 엔터프라이즈 자바빈즈(Enterprise JavaBeans, EJB)라는 엔터프라이즈 에디션(Java Enterprise Edition, Java EE)으로 애플리케이션을 개발했습니다. 하지만 EJB 기술의 복잡도가 증가함에 따라 EJB로 애플리케이션을 개발하는 것이 어려워졌고, 개발자들은 좀 더 쉽게 개발할 방법을 요구하게 됩니다. 이 시기를 개발자들은 자바의 겨울에 비유하였고, 스프링이 등장함으로써 겨울이 끝나고 봄이 찾아오게 될 것이라는 의미로 스프링 이라는 이름이 지어졌다고 합니다.

Spring Framework

스프링은 Spring Framework, Spring Boot, Spring MVC, Spring Data 등 다양한 프로젝트로 구분되며, 기본이 되는 프로젝트는 스프링 프레임워크(Spring Framework) 입니다. 스프링 프레임워크은 약 20개의 모듈로 구성되어 있으며, 아키텍처는 다음과 같습니다.

img01

이 중에서도 핵심은 코어 모듈인 Core Container입니다. 코어 컨테이너에서 Beans 모듈은 IOC 패턴을 통해 객체의 구성부터 의존성 처리까지 맡고 있는 가장 핵심적인 모듈이라 할 수 있습니다.

Spring MVC

스프링 MVC는 이전에 설명했던 MVC 패턴 기반의 웹 프레임워크 입니다. MVC2모델의 발전된 형태의 구조를 하고 있습니다.

img02

스프링 MVC는 Front Controller인 DispatcherServlet에서 모든 요청(Request)을 받아 각 컨트롤러로 요청을 위임하는 형태입니다.

디스패처 서블릿은 다음과 같은 상속 구조를 가지고 있습니다.

img03

HTTP 프로토콜 서비스를 지원하는 HttpServlet을 상속받고 있는데 HttpServlet의 service 메소드를 통해 DispatcherServlet의 doDispatch 메소드가 호출됩니다.

doDispatch는 사용자의 요청에 대하여 적절한 컨트롤러를 찾아 매핑해주며, View까지 찾아서 렌더링 해주는 핵심적인 메소드입니다.

아래 메소드를 보면 HandlerAdapter를 통해 핸들러를 실행시켜 ModelAndView 객체를 생성하고, processDispatchResult를 호출하는 것을 확인할 수 있습니다.

dispatherServlet 클래스의 doDispatch 메소드
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HttpServletRequest processedRequest = request;
    HandlerExecutionChain mappedHandler = null;
    boolean multipartRequestParsed = false;

    WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

    try {
        ModelAndView mv = null;
        Exception dispatchException = null;

        try {
            processedRequest = checkMultipart(request);
            multipartRequestParsed = (processedRequest != request);

            // Determine handler for the current request.
            mappedHandler = getHandler(processedRequest);
            if (mappedHandler == null) {
                noHandlerFound(processedRequest, response);
                return;
            }

            // Determine handler adapter for the current request.
            HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

            // Process last-modified header, if supported by the handler.
            String method = request.getMethod();
            boolean isGet = HttpMethod.GET.matches(method);
            if (isGet || HttpMethod.HEAD.matches(method)) {
                long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
                if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
                    return;
                }
            }

            if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                return;
            }

            // Actually invoke the handler.
            mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

            if (asyncManager.isConcurrentHandlingStarted()) {
                return;
            }

            applyDefaultViewName(processedRequest, mv);
            mappedHandler.applyPostHandle(processedRequest, response, mv);
        }
        catch (Exception ex) {
            dispatchException = ex;
        }
        catch (Throwable err) {
            // As of 4.3, we're processing Errors thrown from handler methods as well,
            // making them available for @ExceptionHandler methods and other scenarios.
            dispatchException = new NestedServletException("Handler dispatch failed", err);
        }
        processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
    }
    catch (Exception ex) {
        triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
    }
    catch (Throwable err) {
        triggerAfterCompletion(processedRequest, response, mappedHandler,
                new NestedServletException("Handler processing failed", err));
    }
    finally {
        if (asyncManager.isConcurrentHandlingStarted()) {
            // Instead of postHandle and afterCompletion
            if (mappedHandler != null) {
                mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
            }
        }
        else {
            // Clean up any resources used by a multipart request.
            if (multipartRequestParsed) {
                cleanupMultipart(processedRequest);
            }
        }
    }
}

Spring Boot

스프링 부트는 스프링 프레임워크의 복잡한 환경설정을 최소화할 수 있도록 구성된 프로젝트 입니다. 스프링 프레임워크의 설정 부분을 자동화 하여 사용자가 편안하게 스프링을 활용할 수 있도록 돕습니다. 의존성으로 spring-boot-starter만 추가해주면 바로 API를 정의하고 내장된 톰켓을 통해 웹 어플리케이션 서버(Web Application Server, WAS)를 실행시킵니다.

심지어 Spring Initializr를 통해 곧바로 실행가능한 코드를 만들 수 있어서 실행환경이나 의존성 관리 등의 인프라스트럭쳐(infrastructure)를 고려하지 않고 코드 개발을 할 수 있습니다.

Spring Data

스프링 데이터는 데이터의 영속성을 위해 사용할 수 있는 모듈의 집합입니다. JDBC, ORM, JPA와 관련된 모듈은 이 프로젝트에 포함되어 있으며, 관계형 데이터베이스, 비관계형 데이터베이스에 관계없이 데이터 접근에 대하여 일관된 방식을 제공합니다.

스프링의 핵심

스프링은 자바 언어 기반의 프레임워크 입니다. 자바 언어의 가장 큰 특징은 객체 지향 언어라는 것입니다.

EJB는 컨테이너가 비즈니스 객체를 관리하여 필요할때마다 컨테이너로부터 객체를 받아쓰는 형식으로 비즈니스 로직의 복잡성 문제를 해결했지만, 문제는 비즈니스 로직이 EJB에 종속된다는 것이었습니다. 하나의 기능을 구현하기 위해 EJB에서 구현된 불필요한 객체들을 상속받아야 했으며, 이러한 이유로 실제 비즈니스 로직보다 EJB를 사용하기 위한 코드가 길어지고 복잡해졌을 뿐만 아니라 객체 지향의 핵심 가치인 상속과 다형성 등을 포기해야 했습니다.

이러한 상황에서 특정 기술에 종속되어 작동하는 객체가 아닌 순수한 자바객체를 뜻하는 POJO(Plain Old Java Object)라는 단어가 만들어 졌습니다. POJO는 말 그대로 오래된 방식의 간단한 자바 오브젝트 입니다. 개발자들은 옛날 객체지향성이 컸던 시절로 돌아가자는 의미에서 POJO를 개발하게 되었습니다.

POJO
public class Board {
    
    private long seq;
    private String title;
    private String content;

    public void setSeq(long seq) {
        this.seq = seq;
    }

    public void getSeq() {
        return this.seq;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public void getTitle() {
        return this.title;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public void getContent() {
        return this.content;
    }
}

위의 코드와 같이 기본적인 필드와 이에 해당하는 getter 및 setter로 구성된 객체를 POJO라고 합니다.

스프링의 핵심 이념은 POJO를 통해 개발하면서 EJB에서 제공하는 엔터프라이즈 서비스를 그대로 사용하자는 것에 있습니다. 즉, 스프링은 특정 기술에 종속되지 않으면서 객체를 관리할 수 있는 컨테이너를 제공합니다. 이것을 가능하게 한 핵심 기술은 IOC/DI, AOP, PSA 입니다.

img04

제어의 반전(Inverson of Control, IoC)

IoC는 코드의 흐름을 제어하는 주체가 바뀌는 패턴을 말합니다.

프레임워크를 사용하지 않는 일반적인 프로그램의 경우 객체의 라이프사이클(생성, 초기화, 소멸, 호출 등)을 클라이언트가 직접 관리합니다.

Cient.java
public class Client {

    public static void main(String[] args) {

        Board board = new Board();
        ...
        board.submit();
    }
}
Board.java
public class Board {
    
    private long seq;
    private String title;
    private String content;

    public Board(long seq, String title, String content) {
        this.seq = seq;
        this.title = title;
        this.content = content;
    }

    public void submit() {
        ...
    }

}

보시는 것처럼 new 키워드를 통해 클라이언트가 직접 Board 객체를 생성하고 Board 객체의 로직을 구현하고 있습니다.

반면 스프링 프레임워크를 사용하면 클라이언트가 객체를 직접 객체를 제어하지 않고 IoC 컨테이너에게 제어권을 양도합니다.

스프링 프레임워크를 통한 프로그래밍
@Controller
public class BoardController {

    @GetMapping("/Board")
    public String getBoarView(Board board) {
        ...
    }
}

보시는 것처럼 클라이언트는 BoardController 객체를 직접 생성하지 않고 @Controller 어노테이션을 선언하는 것 만으로도 구현한 로직을 작동시킬 수 있습니다.

IOC를 구현하는 방법은 다양한데, 대표적으로 템플릿 메소드 패턴(Template Method Pattern)을 통해서 구현될 수 있습니다.

img05

템플릿 메소드 패턴은 알고리즘의 구조를 메소드에 정의하고, 하위 클래스에서 알고리즘 구조의 변경없이 알고리즘을 재정의 하는 패턴입니다. 객체지향 프로그래밍을 한다면 무의식적으로 사용하게 되는 패턴입니다.

예제 코드를 하나 작성해 보겠습니다.

Ramen.java
public abstract class Ramen {
    
    public void make() {
        boilWater();
        putNoodles();
        putIngredients();
        waiting();
        complete();
    }

    public void boilWater() {
        System.out.println("물을 끓입니다.");
    }

    public void putNoodles() {
        System.out.println("면을 넣습니다.");
    }

    public abstract void putIngredients();

    public void waiting() {
        System.out.println("3분을 기다립니다.");
    }

    public abstract void complete();
}
KimchiRamen.java
public class KimchiRamen extends Ramen {

    @Override
    public void complete() {
        System.out.println("김치라면 완성!!");
    }

    @Override
    public void putIngredients() {
        System.out.println("김치를 넣습니다.");
    }
    
}
SeafoodRamen.java
public class SeafoodRamen extends Ramen {

    @Override
    public void complete() {
        System.out.println("해물라면 완성!!");
    }

    @Override
    public void putIngredients() {
        System.out.println("해물을 넣습니다.");
    }
    
}
CheeseRamen.java
public class CheeseRamen extends Ramen {

    @Override
    public void complete() {
        System.out.println("치즈라면 완성!!");
    }

    @Override
    public void putIngredients() {
        System.out.println("치즈를 넣습니다.");
    }
    
}

라면을 끓이는 로직입니다. 상위 클래스인 Ramen은 추상 클래스이며, make 메소드에는 라면을 끓이는 순서를 정의해 두었습니다. 또한 공통 메소드를 구현하고 재료투입 로직인 putIngredients 메소드와 완성 로직인 complete 메소드는 abstract 키워드를 통해 추상메소드로 선언했습니다.

Ramen을 상속받는 구현체인 KimchiRamen, SeafoodRamen CheeseRamen은 재료투입 로직과 완성 로직을 각각 구현했습니다.

재료를 투입하고 완성시키는 로직은 하위 클래스에서 구현했지만 해당 메소드가 호출되는 시점은 상위 클래스의 make 로직에 의하여 결정됩니다. 즉, 제어의 역전이 일어났습니다.

스프링은 IoC 컨테이너를 통해 객체의 흐름을 제어합니다. 개발자는 객체의 제어권을 IoC 컨테이너에 양도함으로써 프로그램의 흐름을 고려할 필요없이 구현로직에만 집중할 수 있게 됩니다.

의존성 주입(Dependency Injection, DI)

DI는 IoC를 구현하는 패턴 중 하나입니다. IoC 컨테이너는 DI를 통해 IoC를 구현합니다. DI는 이름에서 알 수 있듯이 객체의 의존 관계를 외부에서 주입시켜주는 패턴입니다.

위에서 작성한 Ramen 클래스를 사용하여 클라이언트가 라면을 끓여보겠습니다.

Client.java
public class Client {
    private Ramen ramen = new SeafoodRamen();

    public void makeRamen() {
        ramen.make();
    }

    public static void main(String[] args) {

        Client client = new Client();

        try {
            client.makeRamen();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

정상적으로 해물라면이 만들어질 것입니다. 그런데 해물라면 로직에 변화가 생긴 경우를 가정해봅시다.

SeafoodRamen.java
public class SeafoodRamen extends Ramen {

    private final String seafood;

    public SeafoodRamen(String seafood) {
        this.seafood = seafood;
    }

    @Override
    public void complete() {
        System.out.println(seafood + "라면 완성!!");
    }

    @Override
    public void putIngredients() {
        System.out.println(seafood + "을(를) 넣습니다.");
    }
}

seafood라는 String 필드가 하나 만들어졌으며, 생성자는 seafood를 요구합니다. 하지만 클라이언트는 seafood를 매개변수로 주지 않았으므로 컴파일 에러가 발생할 것입니다. 즉, SeafoodRamen 클래스를 수정하면 Client 클래스도 함께 수정해야 프로그램이 정상작동합니다.

이런 경우 Client는 SeafoodRamen에 의존한다. 라고 표현합니다. Client 클래스 내부에 SeafoodRamen 객체를 생성하고 있기 때문에 의존관계가 고정되어 있습니다. 즉, 클라이언트는 해물라면에 강하게 의존하고 있습니다.

코드를 다음과 같이 변경해 보겠습니다.

Client.java
public class Client {
    private Ramen ramen;

    public Client(Ramen ramen) {
        this.ramen = ramen;
    }

    public void makeRamen() {
        ramen.make();
    }

    public static void main(String[] args) {

        Ramen ramen = new SeafoodRamen("새우");
        Client client = new Client(ramen);

        try {
            client.makeRamen();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

여전히 Client는 Ramen에 의존하고 있지만 의존대상인 Ramen을 외부로부터 주입받았습니다. 정상적인 Ramen 객체를 주입받는다면 Client 클래스는 수정하지 않더라도 프로그램이 정상작동할 수 있게 됩니다. 또한 해물라면 뿐만 아니라 김치라면, 치즈라면 등 다양한 라면을 끓일 수 있습니다. 즉, 해물라면에 대한 의존관계를 약화시키고 다형성을 확보할 수 있게 되었습니다.

DI를 하는 방법은 3가지가 있습니다.

생성자 주입(Constructor Injection)

위에 예제에서 구현한 의존성 주입 방식으로, 생성자 주입은 스프링에서 권장하고 있는 방식입니다.

Client.java
public class Client {
    private Ramen ramen;

    public Client(Ramen ramen) {
        this.ramen = ramen;
    }

    public void makeRamen() {
        ramen.make();
    }
}

세터 주입(Setter Injection)

자바빈 패턴으로 세터를 통해 의존성을 주입받습니다.

Client.java
public class Client {
    private Ramen ramen;

    public void setRamen(Ramen ramen) {
        this.ramen = ramen;
    }

    public void makeRamen() {
        ramen.make();
    }
}

인터페이스 주입(Interface Injection)

인터페이스를 통해 의존성 주입을 담보받는 형식입니다. Ramen을 주입받는 Client는 해당 인터페이스의 구현체가 됩니다.

Client.java
public class Client implements RamenInject {
    private Ramen ramen;

    @Override
    public void injectRamen(Ramen ramen) {
        this.ramen = ramen;
    }

    public void makeRamen() {
        ramen.make();
    }
}
RamenInject.java
public interface RamenInject {
    public void injectRamen(Ramen ramen);
}

이처럼 스프링은 DI를 통해 IOC를 구현하면서 객체 사이의 의존성을 약화시키고 다형성을 확보합니다. 스프링으로 컨트롤러를 만들어보겠습니다.

BoardController.java
@Controller
public class BoardController {

    private final BoardService boardService;

    public BoardController(BoardService service) {
        this.boardService = service;
    }

    ...

BoardControllerBoardService에 의존하고 있다는 것을 확인할 수 있습니다.

@Controller 어노테이션을 붙이면 스프링의 IoC 컨테이너에서 관리하는 객체인 Bean으로 등록되어 객체가 자동으로 생성됩니다. 이때 자동으로 의존성 주입이 일어나는데 @Autowired 어노테이션을 붙임으로써 의존성을 주입받습니다.

그런데 위에 작성한 코드에는 @Autowired 어노테이션이 없습니다. 이것은 생성자가 1개인 경우 IoC 컨테이너가 자동으로 이를 인식하여 의존성을 주입해 주기때문입니다.

스프링에서 DI를 하는 방식은 다음과 같습니다.

필드 주입(Fild Injection)

필드 주입은 의존성을 주입할 필드에 @Autowired 어노테이션을 붙입니다.

BoardController.java
@Controller
public class BoardController {

    @Autowired
    private BoardService boardService;

    ...
}

필드 주입은 가장 간단하면서도 가장 추천되지 않는 방식입니다. 인텔리제이에서 필드 주입을 하면 경고를 나타내는 것을 확인할 수 있습니다.

img05

필드 주입의 경우 외부에서 접근이 불가능합니다. 이것은 다시 말해서 DI 프레임워크나 리플렉션 없이는 필드값을 주입해 줄 방법이 없다는 것을 의미합니다. 즉, 프레임워크에 강하게 종속적인 객체가 되어버립니다.

또한 필드 주입은 순환참조 문제가 발생할 수 있습니다. 다음 코드를 확인해봅시다.

Chicken.java
@Component
public class Chicken {
    
    @Autowired
    private Egg egg;

    public void layEgg() {
        egg.becomeChicken();
    }
}
Egg.java
@Component
public class Egg {
    
    @Autowired
    private Chicken chicken;

    public void becomeChicken() {
        chicken.layEgg();
    }
}

닭은 알에 의존하고 알은 닭에 의존하고 있는 형태입니다. 닭은 알을 놓고, 알은 닭이 됩니다.

이 코드는 컴파일 시점에서는 문제가 되지 않지만 런타임 시점에서 메소드를 호출하는 순간 스택오버플로우가 발생합니다. 서로 메소드를 참조하여 무한하게 순환하기 때문입니다. 즉, 컴파일 시점에서 에러를 발견할 수 없다는 문제점이 있습니다.

이러한 이유로 필드 주입은 지양해야합니다.

세터 주입(Setter Injection)

세터 주입은 Setter에 @Autowired 어노테이션을 붙여 의존성을 주입합니다.

BoardController.java
@Controller
public class BoardController {
    
    private BoardService boardService;

    @Autowired
    public void setBoardService(BoardService boardservice) {
        this.boardService = boardservice;
    }
    ...
}

세터 주입 역시 순환참조 문제가 발생할 수 있습니다. 하지만 의존성을 수정할 수 있기 때문에 런타임에서 의존성 수정이 필요한 경우 사용합니다.

생성자 주입(Constructor Injection)

생성자 주입은 스프링에서 공식적으로 추천하는 방식입니다.

BoardController.java
@Controller
public class BoardController {
    
    private final BoardService boardService;

    public BoardController(BoardService boardservice) {
        this.boardService = boardservice;
    }
    ...
}

생성자 주입의 경우 최초 빈(Bean)의 생성시 1회 호출을 보장하며, 다른 방식과 달리 필드에 final 키워드를 사용가능합니다. 또한 컴파일 시점에서 순환참조를 체크하기 때문에 에러를 초기에 발견할 수 있습니다.

위에서 설명했듯이 단일 생성자의 경우 어노테이션을 붙이지 않아도 되며, 생성자가 여러 개인 경우 매개변수가 가장 많은 생성자에 의존성을 주입합니다. @Autowired 어노테이션을 붙여 명시적으로 의존성 주입을 위한 생성자를 지정할 수도 있습니다.

관점 지향 프로그래밍(Aspect-Oriented Programming, AOP)

AOP는 관심사(기능)를 분리하여 개발의 복잡성을 낮추기 위한 프로그래밍 패러다임의 하나입니다.

img06

객체 지향 프로그래밍(Object Oriented Programming, OOP)에서는 주요 관심사에 따라 클래스를 분할하는데, 이 클래스들은 단일 책임 원칙(Single Responsibility Principle, SRP)에 따라 설계되어 있습니다. 하지만 클래스를 설계하다보면 로깅, 보안, 트랜잭션 등 공통적으로 사용하는 부가기능이 생길 수 있습니다. 이때 공통되지 않는 주요 비즈니스 로직을 핵심 관심사(Core Concern)라고 하며, 주요한 비즈니스 로직은 아니지만 반복적으로 사용되는 관심사를 횡단 관심사(Cross Cutting Concern)라고 합니다.

AOP에서 관점(Aspect)이란 이러한 횡단 관심사를 하나로 모아 모듈화 시킨 것을 의미합니다. 즉, AOP는 흩어져 있는 횡단 관심사를 별도의 클래스로 모듈화하여 OOP에 도움을 주는 부가적인 기능이라 할 수 있습니다.

스프링에서 AOP는 프록시 패턴(Proxy Pattern)을 통해 구현됩니다. 프록시 패턴은 다른 객체로의 접근을 통제하기 위해 그 객체의 대리자를 두는 패턴입니다.

img07

프록시는 인터페이스를 통해 실제 서비스와 동일한 이름의 메소드를 구현하여 실제 서비스에 대한 직접적인 접근을 제어할 수 있으며, 메소드 호출 전후에 별도의 로직을 수행하도록 할 수 있습니다.

프록시 패턴의 예제 코드를 작성해 보겠습니다.

Target.java
public interface Target {
    public void logic();
}
TargetImpl.java
public class TargetImpl implements Target {

    @Override
    public void logic() {
        System.out.println("비즈니스 로직 수행");
    }
}
TargetProxy.java
public class TargetProxy implements Target {
    
    private final Target target;

    public TargetProxy(Target target) {
        this.target = target;
    }

    private void before() {
        System.out.println("비즈니스 로직 수행 전 실행");
    }

    private void after() {
        System.out.println("비즈니스 로직 수행 후 실행");
    }

    @Override
    public void logic() {
        before();
        target.logic();
        after();
    }
}
Client.java
public class Client {

    public static void main(String[] args) {
        Target target = new TargetImpl();
        Target proxy = new TargetProxy(target);
        proxy.logic();
    }
}

위에 코드와 같이 프록시를 통해 비즈니스 로직을 수행하면 비즈니스 로직 전후로 부가적인 기능을 추가할 수 있게 됩니다. 스프링 AOP의 대표적인 예로 트랜잭션 처리를 위한 @Transactional 어노테이션이 있습니다. 해당 어노테이션이 붙은 메소드는 메소드의 실행 전 autoCommit(false) 처리를 하고, 메소드 실행 후 commit() 처리를 합니다. 또한 트랜잭션 오류가 발생하면 rollback()이 수행되도록 합니다.

교체가능한 서비스 추상화(Portable Service Abstraction, PSA)

PSA는 환경의 변화 및 세부기술의 변경과 상관없이 일관된 방식으로 기술에 접근할 수 있게 해주는 설계원칙을 말합니다.

PSA는 POJO 원칙을 철저하게 따르는 스프링의 기능으로, 스프링에서 동작하는 라이브러리들은 POJO 원칙을 지키게끔 PSA 형태로 추상화 되어있습니다. 즉, 복잡한 기술은 내부에 숨기면서 개발자에게 편의성을 제공합니다.

예를 들어 트랜잭션을 처리하는 경우, 놀랍게도 JDBC를 통해 DB에 접근하든 JPA를 통해 DB에 접근하든 @Transactional 어노테이션을 선언하는 것 만으로도 별도의 코드를 추가할 필요없이 트랜잭션 서비스를 사용할 수 있습니다.