Backend
Spring Boot 백엔드 애플리케이션 구조와 데이터 접근 흐름
회원 관리 예제를 기준으로 Spring Boot의 MVC, DI, Repository, 테스트, JDBC/JPA, AOP가 애플리케이션 구조 안에서 어떤 문제를 해결하는지 정리합니다.

Spring Boot를 처음 다룰 때는 @Controller, @Service, @Repository, @Autowired, @Transactional 같은 애노테이션이 먼저 눈에 들어온다. 하지만 애노테이션을 외우는 방식으로 접근하면 구조가 잘 잡히지 않는다. 더 중요한 것은 요청이 들어왔을 때 어떤 객체가 어떤 책임을 맡고, 데이터 저장 방식이 바뀌어도 어느 부분이 흔들리지 않아야 하는지 보는 것이다.
작은 회원 관리 예제를 기준으로 보면 흐름이 단순해진다. 요구사항은 회원을 등록하고 조회하는 정도다. 처음에는 데이터베이스가 정해져 있지 않다고 가정하고 메모리 저장소로 시작한다. 이후 H2, JDBC, JdbcTemplate, JPA, Spring Data JPA로 저장소 구현을 바꿔도 컨트롤러와 서비스의 핵심 흐름은 크게 바뀌지 않아야 한다. 이 지점에서 레이어링, 인터페이스, DI, 테스트가 자연스럽게 필요해진다.
프로젝트를 만들 때 먼저 확인한 것
Spring Boot 3.x를 기준으로 하면 Java 17 이상이 필요하다. 예전 Spring 예제에서 보던 javax.persistence.Entity나 javax.annotation.PostConstruct는 Jakarta EE 전환 이후 jakarta.persistence.Entity, jakarta.annotation.PostConstruct처럼 패키지가 바뀌었다. 프로젝트를 생성할 때 이 차이를 놓치면 의존성은 맞는 것 같은데 import가 깨지는 상황이 생긴다.
초기 프로젝트는 Spring Initializr에서 다음 정도로 시작할 수 있다.
Project: Gradle
Language: Java
Spring Boot: 3.x
Packaging: Jar
Java: 17 or 21
Dependencies: Spring Web, Thymeleaf, Spring Data JPA, H2 Databasebuild.gradle은 처음부터 많은 의존성을 넣기보다 웹 요청을 처리하고, 템플릿을 렌더링하고, 테스트를 돌릴 수 있는 최소 구성으로 잡는다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.0'
id 'io.spring.dependency-management' version '1.1.5'
}
group = 'hello'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}Spring Boot는 resources/static/index.html이 있으면 정적 welcome page로 제공한다. 하지만 / 경로를 처리하는 컨트롤러가 생기면 컨트롤러가 우선한다. 이 우선순위를 알고 있어야 정적 파일이 보이지 않는 상황과 MVC 라우팅이 의도대로 동작하는지 구분할 수 있다.
요청 처리 방식은 세 가지로 나눠서 보면 쉽다
Spring Web에서 처음 확인할 흐름은 정적 콘텐츠, MVC 템플릿, API 응답이다. 정적 콘텐츠는 컨트롤러 없이 resources/static 아래의 파일을 그대로 제공한다. 반면 MVC는 컨트롤러가 요청을 받고 모델에 데이터를 담은 뒤 View 템플릿을 렌더링한다. API는 View를 거치지 않고 JSON이나 문자열을 응답 본문으로 직접 반환한다.
@Controller
public class HelloController {
@GetMapping("/hello-mvc")
public String helloMvc(@RequestParam String name, Model model) {
model.addAttribute("name", name);
return "hello-template";
}
@GetMapping("/hello-api")
@ResponseBody
public HelloResponse helloApi(@RequestParam String name) {
return new HelloResponse(name);
}
public record HelloResponse(String name) {
}
}@ResponseBody가 붙으면 View Resolver 대신 HttpMessageConverter가 동작한다. 문자열이면 StringHttpMessageConverter, 객체면 보통 Jackson 기반 JSON converter가 선택된다. API 서버를 만들 때 이 차이는 중요하다. 같은 컨트롤러 메서드라도 반환 타입과 애노테이션에 따라 HTML 렌더링이 될 수도 있고 JSON 응답이 될 수도 있기 때문이다.
회원 도메인을 작게 잡고 레이어를 분리한다
회원 관리 예제의 요구사항은 단순하다.
- 회원은
id와name을 가진다. - 회원을 등록할 수 있다.
- 전체 회원을 조회할 수 있다.
- 같은 이름의 회원은 중복 등록하지 않는다.
- 저장소는 나중에 바뀔 수 있다.
이 요구사항에서 바로 데이터베이스 테이블부터 만들 수도 있지만, 먼저 도메인과 저장소 인터페이스를 분리하면 저장 방식 변경에 덜 흔들린다.
package hello.hellospring.domain;
public class Member {
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id);
Optional<Member> findByName(String name);
List<Member> findAll();
}초기에는 메모리 구현체로 충분하다. 단, 예제 코드의 HashMap과 단순 long sequence는 동시성에 안전하지 않다. 실제 요청이 동시에 들어오는 웹 서버라면 ConcurrentHashMap과 AtomicLong을 쓰거나, 더 나아가 데이터베이스의 제약 조건과 트랜잭션으로 중복을 막아야 한다.
public class MemoryMemberRepository implements MemberRepository {
private final Map<Long, Member> store = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
@Override
public Member save(Member member) {
member.setId(sequence.incrementAndGet());
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}서비스는 비즈니스 규칙을 숨기지 않는다
컨트롤러가 직접 리포지토리를 호출해도 기능은 동작한다. 하지만 중복 회원 검증 같은 규칙이 컨트롤러마다 흩어지면 API, MVC 화면, 배치, 테스트에서 같은 규칙을 반복하게 된다. 서비스는 이런 도메인 규칙을 한 곳으로 모으는 역할을 한다.
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public Long join(Member member) {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}
public List<Member> findMembers() {
return memberRepository.findAll();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(existing -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
}여기서 눈여겨볼 부분은 new MemoryMemberRepository()를 서비스 내부에서 직접 만들지 않는다는 점이다. 서비스는 MemberRepository 인터페이스에만 의존한다. 어떤 구현체를 넣을지는 외부 설정이 결정한다. 이 구조 덕분에 메모리 저장소에서 JDBC, JPA로 넘어가도 서비스 코드는 대부분 유지된다.
DI는 객체 생성 위치를 바꾸는 설계 도구다
Spring에서 의존성 주입은 단순히 @Autowired를 붙이는 일이 아니다. 객체가 자기 의존성을 직접 생성하지 않게 만들고, 실행 환경에 맞는 구현체를 외부에서 조립하는 방식이다.
컴포넌트 스캔을 쓰면 @Controller, @Service, @Repository가 붙은 클래스를 Spring이 찾아 Bean으로 등록한다.
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}반대로 구현체를 명시적으로 교체할 가능성이 큰 경우에는 Java 설정으로 Bean을 등록하는 편이 흐름을 읽기 쉽다.
@Configuration
public class SpringConfig {
@Bean
public MemberService memberService(MemberRepository memberRepository) {
return new MemberService(memberRepository);
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}DI 방식은 필드 주입, setter 주입, 생성자 주입이 있지만 대부분 생성자 주입을 선택하는 편이 좋다. 필수 의존성을 누락한 상태로 객체가 만들어지는 일을 막을 수 있고, 테스트에서도 가짜 구현체를 넣기 쉽다.
테스트는 저장소의 상태와 순서를 분리해야 한다
메모리 리포지토리는 테스트하기 쉽지만, 상태가 남는다는 문제가 있다. 하나의 테스트가 저장한 데이터가 다음 테스트에 영향을 주면 테스트 순서에 따라 성공과 실패가 바뀐다. 그래서 각 테스트 후 저장소를 비운다.
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
void afterEach() {
repository.clearStore();
}
@Test
void save() {
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).orElseThrow();
assertThat(result).isEqualTo(member);
}
}서비스 테스트에서는 리포지토리 구현을 주입해 비즈니스 규칙을 확인한다.
class MemberServiceTest {
MemoryMemberRepository repository;
MemberService memberService;
@BeforeEach
void beforeEach() {
repository = new MemoryMemberRepository();
memberService = new MemberService(repository);
}
@AfterEach
void afterEach() {
repository.clearStore();
}
@Test
void duplicateMemberIsRejected() {
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
memberService.join(member1);
assertThatThrownBy(() -> memberService.join(member2))
.isInstanceOf(IllegalStateException.class);
}
}이 테스트 구조가 잡혀 있으면 저장소 구현을 바꾸더라도 “회원 중복 검증” 같은 규칙이 깨졌는지 바로 확인할 수 있다.
MVC 화면은 입력 객체와 도메인을 구분한다
회원 등록 화면은 GET /members/new로 폼을 보여주고, POST /members/new로 등록 요청을 받는다. 이때 HTML form의 입력값을 바로 도메인 객체에 묶기보다 별도의 form 객체로 받으면 화면 입력과 도메인 모델의 변경을 분리할 수 있다.
@Controller
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/members/new")
public String createForm() {
return "members/createMemberForm";
}
@PostMapping("/members/new")
public String create(MemberForm form) {
Member member = new Member();
member.setName(form.getName());
memberService.join(member);
return "redirect:/";
}
@GetMapping("/members")
public String list(Model model) {
model.addAttribute("members", memberService.findMembers());
return "members/memberList";
}
}public class MemberForm {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}Thymeleaf에서는 서버에서 전달한 members를 반복 렌더링한다.
<table>
<thead>
<tr>
<th>#</th>
<th>이름</th>
</tr>
</thead>
<tbody>
<tr th:each="member : ${members}">
<td th:text="${member.id}"></td>
<td th:text="${member.name}"></td>
</tr>
</tbody>
</table>이 예제는 단순하지만, API 서버에서도 같은 패턴을 적용할 수 있다. Request DTO로 입력을 받고, 서비스에서 도메인 규칙을 처리하고, Response DTO로 외부 응답을 구성한다. 도메인 객체와 외부 API 스키마가 강하게 묶이지 않게 하는 것이 핵심이다.
REST API로 바꾸면 컨트롤러의 책임은 더 분명해진다. 컨트롤러는 HTTP 요청을 애플리케이션 명령으로 바꾸고, 서비스의 결과를 응답 DTO로 변환한다. 도메인 객체를 그대로 응답으로 노출하면 내부 필드 변경이 API 계약 변경으로 이어질 수 있기 때문에, 외부에 보여줄 형태를 별도로 둔다.
@RestController
@RequestMapping("/api/members")
public class MemberApiController {
private final MemberService memberService;
public MemberApiController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping
public ResponseEntity<MemberResponse> create(@Valid @RequestBody CreateMemberRequest request) {
Long memberId = memberService.join(request.toCommand());
return ResponseEntity
.status(HttpStatus.CREATED)
.body(new MemberResponse(memberId, request.name()));
}
}
public record CreateMemberRequest(
@NotBlank String name
) {
public CreateMemberCommand toCommand() {
return new CreateMemberCommand(name);
}
}
public record MemberResponse(Long id, String name) {
}이 구조에서 CreateMemberRequest는 HTTP 입력을 표현하고, CreateMemberCommand는 서비스가 이해하는 애플리케이션 명령을 표현한다. 작은 예제에서는 둘을 합쳐도 동작하지만, 필드 검증, 권한, 요청 출처, idempotency key, 파일 업로드 같은 요소가 들어오면 입력 DTO와 도메인 명령을 나누는 편이 안전하다.
예외 응답도 마찬가지다. 서비스에서 발생한 예외가 그대로 HTML 오류 페이지나 stack trace로 노출되면 API 사용자는 실패 원인을 안정적으로 처리하기 어렵다. @ControllerAdvice를 두면 오류 응답 형식을 한곳에서 맞출 수 있다.
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiErrorResponse> handleBadRequest(IllegalArgumentException ex) {
return ResponseEntity.badRequest()
.body(new ApiErrorResponse("BAD_REQUEST", ex.getMessage()));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ApiErrorResponse> handleConflict(IllegalStateException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiErrorResponse("CONFLICT", ex.getMessage()));
}
}
public record ApiErrorResponse(String code, String message) {
}운영 중에는 오류 메시지보다 오류 코드가 더 중요해지는 경우가 많다. 프론트엔드, 배치, 외부 연동 시스템은 사람이 읽는 문장이 아니라 안정적인 코드로 분기해야 하기 때문이다. 그래서 API 응답은 성공 케이스뿐 아니라 실패 케이스까지 계약으로 봐야 한다.
DB 접근 기술은 반복 코드와 모델링 방식의 차이다
H2는 개발과 테스트에서 가볍게 쓰기 좋다. Spring Boot 3.x에서는 H2 2.x 계열을 맞추는 것이 좋고, 파일 모드로 한 번 생성한 뒤 TCP 모드로 접속하면 애플리케이션과 콘솔을 함께 사용할 수 있다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true테이블은 작게 시작한다.
drop table if exists member cascade;
create table member (
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);순수 JDBC는 Connection, PreparedStatement, ResultSet을 직접 다뤄야 한다. 이 과정을 한 번 해보면 DB 접근에서 어떤 반복이 발생하는지 알 수 있다. 하지만 운영 코드에서 계속 직접 작성하기에는 리소스 정리, 예외 변환, 매핑 코드가 많다.
JdbcTemplate은 이 반복을 줄여준다. SQL은 직접 쓰지만 connection 관리와 반복 코드를 프레임워크가 처리한다.
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate jdbcTemplate;
public JdbcTemplateMemberRepository(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public Optional<Member> findById(Long id) {
List<Member> result = jdbcTemplate.query(
"select * from member where id = ?",
memberRowMapper(),
id
);
return result.stream().findAny();
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}JPA는 관점이 조금 다르다. SQL 반복을 줄이는 것뿐 아니라 객체와 테이블의 매핑을 중심에 둔다. 엔티티는 식별자와 필드를 가지고, EntityManager가 영속성 컨텍스트 안에서 변경을 추적한다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}public class JpaMemberRepository implements MemberRepository {
private final EntityManager em;
public JpaMemberRepository(EntityManager em) {
this.em = em;
}
@Override
public Member save(Member member) {
em.persist(member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(em.find(Member.class, id));
}
@Override
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
}데이터 변경은 트랜잭션 안에서 수행되어야 한다.
@Transactional
public Long join(Member member) {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}Spring Data JPA는 여기서 한 단계 더 나아가 리포지토리 구현체를 직접 만들지 않아도 기본 CRUD와 메서드 이름 기반 쿼리를 제공한다.
public interface SpringDataJpaMemberRepository
extends JpaRepository<Member, Long>, MemberRepository {
Optional<Member> findByName(String name);
}기술 선택을 단순히 “최신 기술이 좋다”로 보면 놓치는 것이 있다. 순수 JDBC는 가장 많은 코드를 요구하지만 DB 접근의 기본을 이해하기 좋다. JdbcTemplate은 SQL 제어권을 유지하면서 반복을 줄인다. JPA는 객체 모델과 트랜잭션 경계를 중심으로 생산성을 높인다. Spring Data JPA는 반복적인 리포지토리 구현을 없애지만, 생성되는 쿼리와 영속성 컨텍스트의 동작은 이해하고 있어야 한다.
트랜잭션은 Repository가 아니라 Service 계층에서 잡는 편이 자연스럽다. Repository는 데이터를 저장하고 조회하는 방법을 숨기고, Service는 어떤 변경들이 하나의 업무 단위로 묶여야 하는지 안다. 회원 가입 예제는 단순하지만, 중복 검증과 저장이 하나의 단위로 처리되어야 한다는 점은 더 큰 시스템에서도 같다.
@Transactional
public Long join(CreateMemberCommand command) {
validateDuplicateMember(command.name());
Member member = new Member(command.name());
memberRepository.save(member);
return member.getId();
}다만 애플리케이션 코드의 중복 검증만으로는 동시 요청을 완전히 막기 어렵다. 두 요청이 거의 동시에 들어와 둘 다 findByName을 통과한 뒤 저장을 시도할 수 있기 때문이다. 그래서 DB의 unique constraint가 마지막 방어선이 되어야 한다.
alter table member
add constraint uk_member_name unique (name);서비스 계층에서는 DB 제약 조건 위반을 도메인에 맞는 오류로 바꿔주는 처리가 필요하다.
try {
memberRepository.save(member);
} catch (DataIntegrityViolationException ex) {
throw new DuplicateMemberException("이미 존재하는 회원입니다.");
}이런 구조를 두면 컨트롤러는 DuplicateMemberException을 409 Conflict로 응답하고, 서비스는 비즈니스 규칙을 표현하며, DB는 동시성 상황에서 정합성을 지킨다. 같은 규칙을 여러 계층에 중복해서 둔다기보다, 각 계층이 맡을 수 있는 방어선을 나눠 갖는 방식이다.
통합 테스트는 Spring 컨테이너와 DB 경계를 확인한다
단위 테스트가 비즈니스 규칙을 빠르게 확인한다면, 통합 테스트는 Spring 설정, 트랜잭션, 실제 DB 접근이 함께 맞물리는지 확인한다.
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
void join() {
Member member = new Member();
member.setName("spring");
Long savedId = memberService.join(member);
Member found = memberRepository.findById(savedId).orElseThrow();
assertThat(found.getName()).isEqualTo(member.getName());
}
}테스트에 @Transactional을 붙이면 테스트가 끝난 뒤 롤백된다. 테스트 데이터가 DB에 남지 않으므로 반복 실행이 쉬워진다. 다만 운영 코드의 트랜잭션 경계와 테스트의 롤백 편의성을 혼동하면 안 된다. 테스트에서만 통과하는 lazy loading, flush timing 문제가 있을 수 있으므로 필요한 경우 명시적으로 flush하거나 별도 슬라이스 테스트를 둔다.
AOP는 공통 관심사를 밖으로 꺼내는 장치다
회원 가입과 조회 메서드의 실행 시간을 측정하고 싶다고 해서 모든 메서드에 System.currentTimeMillis()를 넣으면 비즈니스 로직이 흐려진다. 시간 측정, 로깅, 트랜잭션, 권한 검사처럼 여러 계층에 걸쳐 반복되는 로직은 공통 관심사로 분리하는 편이 낫다.
@Aspect
@Component
public class TimeTraceAop {
@Around("execution(* hello.hellospring..*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long elapsed = System.currentTimeMillis() - start;
System.out.println(joinPoint.getSignature() + " " + elapsed + "ms");
}
}
}Spring AOP는 프록시 기반으로 동작한다. 컨테이너는 실제 객체를 그대로 주입하는 대신 프록시 객체를 주입하고, 프록시가 메서드 호출 전후에 공통 로직을 실행한 뒤 실제 객체를 호출한다. 이 구조 때문에 self-invocation처럼 같은 객체 내부에서 메서드를 직접 호출하는 경우 AOP가 적용되지 않는 상황도 생긴다. AOP는 편리하지만, 프록시 경계와 적용 범위를 이해해야 한다.
정리
Spring Boot 백엔드 구조는 애노테이션의 나열이 아니라 책임을 분리하는 과정으로 보는 편이 이해하기 쉽다. 컨트롤러는 HTTP 요청과 응답을 다루고, 서비스는 도메인 규칙을 모으며, 리포지토리는 데이터 접근 방식을 숨긴다. DI는 구현체 교체와 테스트 가능성을 만들고, 트랜잭션은 데이터 변경의 경계를 잡는다. AOP는 여러 계층에 반복되는 관심사를 분리한다.
작은 회원 관리 예제는 단순하지만, 이 흐름은 더 큰 서비스에도 그대로 이어진다. 저장소가 메모리에서 RDB로 바뀌고, 화면이 Thymeleaf에서 REST API로 바뀌고, 배포 환경이 로컬에서 컨테이너와 Kubernetes로 바뀌어도 핵심 질문은 같다. 요청은 어느 계층에서 해석되고, 규칙은 어디에 모이며, 데이터 접근은 어떤 추상화 뒤에 숨고, 실패와 변경을 테스트로 어떻게 확인할 것인가.
이 글의 무단 복제, 재배포, 자동 수집을 허용하지 않습니다. 인용이 필요한 경우 출처와 원문 링크를 함께 남겨주세요.