Studying/스프링부트와 AWS로 혼자 구현하는 웹 서비스

[스프링부트와 AWS로 혼자 구현하는 웹 서비스]_3. (1) JPA와 데이터베이스

mh030128 2025. 7. 3. 15:58

웹 서비스와 데이터베이스를 다루는 일은 개발하고 운영할 때 피할 수 없는 문제임.

 

웹 서비스를 개발하며 데이터베이스를 사용하는 방법에는,

MyBatis와 같은 SQL 매퍼 사용하기, 다른 하나는 ORM을 이용하여 객체 매핑하기가 있음.

 

MyBatis도 많이 사용되고 있는 서비스지만, 개발을 하는 시간보다 SQL을 다루는 시간이 더 많음.

JPA(Java Persistence API)라는 자바 표준 ORM(Object Relational Mapping)을 이용하여 객체를 매핑하는 방법이 존재함.

(MyBatis, iBatis는 ORM이 아님. SQL Mapper임.)

 

1. JPA

 

웹 애플리케이션에서 관계형 데이터베이스(RDB, Relational Database)는 빠질 수 없는 요소임.

Oracle, MySQL, MSSQL 등을 사용하지 않는 웹 애플리케이션이 거의 없을 정도임.

그러다 보니 객체를 관계형 데이터 베이스에서 관리하는 것이 무엇보다 중요.

RDB가 계속해서 웹 서비스의 중심이 되면서 모든 코드는 SQL 중심이 되어감. 그 이유는 SQL만 인식할 수 있기 때문.

현업에서는 수십, 수백 개의 테이블을 관리하는데 이 테이블의 몇 배의 SQL을 만들고 유지보수하는 것은 쉬운 일이 아님.

뿐만 아니라, 관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 달라 객체를 데이터베이스에 저장할 때 발생하는 패러다임 불일치가 발생함.

관계형 데이터베이스는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술이고, 객체지향 프로그래밍 언어는 메시지를 기반으로 기능과 속성을 한 곳에서 관리하는 기술임.

따라서 방향이 다른 둘을 한 번에 사용하려니 쉽지 않음.

예를 들어, 객체지향 프로그래밍에서 부모가 되는 객체를 가져오려면 방법은 다음과 같음.

User user = findUser();
Group group = user.getGroup();

 

한눈에 봐도, User와 Group은 부모 - 자식 관계임을 알 수 있음. User가 본인이 속한 Group을 가져온 코드이기 때문.

하지만, 여기에 데이터베이스가 추가되면 다음과 같이 변경됨.

User user = userDao.findUser();
Group group = groupDto.findGroup(user.getGroupId());

 

딱 봐도 복잡해진 것을 볼 수 있음.

User 따로, Group 따로 조회하게 되며, 서로 어떤 과계인지 알 수 없음.

뿐만 아니라 상속, 1:N 등 다양한 객체 모델링을 데이터베이스로는 구현할 수 없음.

그러다 보니 웹 개발은 점점 데이터베이스 모델링에만 집중하게 됨.

 

이러한 문제를 해결하기 위해 등장한 것이 JPA임.

한마디로 정리하면, 중간에서 패러다임 일치를 시켜주기 위한 기술이라고 생각하면 됨.

즉, 개발자는 객체지향적으로 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행함.

그러므로 개발자는 항상 객체지향적으로 코드를 표현할 수 있어 더는 SQL에 종속적인 개발을 하지 않아도 됨.

 

객체 중심으로 개발하면,

생산성 향상 뿐만 아니라 유지 보수가 정말 편해서 365일 24시간, 대규모 트래픽가 데이터를 가진 서비스에서 JPA는 점점 표준 기술로 자리 잡음.

 

JPA는 인터페이스로서 자바 표준명세서임. 따라서 인터페이스인 JPA를 사용하기 위해서는 구현체가 필요.

대표적으로는 Hibernate, Eclipse Link 등이 있음.

구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용하여 JPA 기술을 다루는데, 

JPA ← Hibernate ← Spring Data JPA의 관계를 가짐.

Hiberante를 쓰는 것과 Spring Data JPA를 쓰는 것 사이에는 큰 차이가 없지만 스프링 진영에서는 Spring Data JPA를 개발하고, 사용하기를 권장함. 그 이유는 크게 두 가지가 있음.

첫째는, 구현체 교체의 용이성이고 둘째는, 저장소 교체의 용이성임.

 

구현체 교체의 용이성이란, Hibernate 외에 다른 구현체로 쉽게 교체하기 위함. Hibernate가 수명을 다하고 새로운 JPA 구현체가 떠오를 때, Spring Data JPA를 사용 중이면 아주 쉽게 교체 가능. 그 이유는 Spring Data JPA 내부에서 구현체 매핑을 지원하기 때문임.

다음으로 저장소 교체의 용이성이란 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함. 관계형 데이터베이스는 초기에 모든 기능을 처리했지만, 점점 트래픽이 많아지면 도저히 감당이 안 될 때가 있음.이 때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됨. 그 이유는 Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문임.

 

따라서 Hibernate를 직접 쓰기 보다는 Spring 팀에서 계속해서 Spring Data 프로젝트를 권장하고 있음.

 

2. 프로젝트에 Spring Data JPA 적용하기

 

먼저 build.gradle에 다음과 같이 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.h2database:h2' 의존성(dependencies) 등록.

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.5.3'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'org.example.boot'
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-web-services'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'com.h2database:h2'

	runtimeOnly 'com.mysql:mysql-connector-j'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}

!설명!

 

spring-boot-start-data-jpa

 - 스프링 부트용 Spring Data Jpa 추상화 라이브러리.

 - 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들의 버전 관리.

 

h2

 - 인메모리 관계형 데이터베이스.

 - 별도 설치 필요 없이 프로젝트 의존성만으로 관리 가능.

 - 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용. 

 

의존성 등록했으면, 본격적으로 JPA 기능 사용해 보기.

먼저 src / main / java / com / jojoldu / book / springboot에 domain 패키지 생성하기.

여기서 domain 패키지는 도메인을 담을 패키지임.

도메인은 게시글, 댓글, 회원 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하면 됨.

domain 패키지 생성한 곳에  posts 패키지 생성 후 Posts 클래스 생성하기.

 

다음으로 Posts 클래스에 다음과 같은 코드 작성하기.

Posts 클래스는 실제 DB의 테이블과 매칭될 클래스이며, 보통 Entity 클래스라고도 함.

JPA 사용하면 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정 통해 작업을 함.

그리고 코드 작성하면서 어노테이션 순서는 주요 어노테이션을 클래스에 가깝게 두기.

package com.jojoldu.book.springboot.domain.posts;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(length = 500, nullable = false)
    private String title;
    
    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;
    
    private String author;
    
    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
}

!설명!

 

@Entity : JPA의 어노테이션.

@Getter, @NoArgsConstructor : 롬복의 어노테이션

→ 여기서 롬복은 코드를 단순화시켜주지만 필수 어노테이션은 아님. 그러니 @Entity를 클래스에 가깝게 둠.

     그 이유는 코틀린 등의 새 언어 전환으로 롬복이 필요 없어질 경우 쉽게 삭제하기 쉬움.

 

@Entity

 - 테이블과 링크될 클래스임을 나타냄.

 - 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭 가능.

 - ex) SalesManager.java → sales_manager table

 

@Id ; 해당 테이블의 PK를 나타냄.

 

@GeneratedValue

 - PK의 생성규칙 나타냄.

 - 스프링 부트 2.0 에서는 Generation Type.IDENTITY 옵션 추가해야만 auto_increment가 됨.

 

@Column

 - 테이블의 칼럼을 나타냄.

 - 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 됨.

 - 사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용.

 - 문자열 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하는 등의 경우에 사용.

 

@NoArgsConstructor

 - 롬복 어노테이션

 - 기본 생성자 자동 추가.

 - public Posts(){ }와 같은 효과

 

@Getter

 - 롬복 어노테이션

 - 클래스 내 모든 필드의 Getter 메서드 자동 생성

 

@Builder

 - 롬복 어노테이션

 - 해당 클래스의 빌더 패턴 클래스를 생성

 - 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함.

 

서비스 초기 구축 단계에선 테이블 설계(여기서는 Entity)가 빈번하게 변경되는데, 이때 롬복의 어노테이션들은 코드 변경량을 최소화시켜 주기 때문에 적극적으로 사용하는 것을 추천.

 

이 Posts 클래스에는 Setter 메서드가 없음.

Entity 클래스에서는 절대 Setter 메서드를 만들지 않음.

그 이유는 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수 없어, 차후 기능 변경 시 복잡해짐.

그래서 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메서드를 추가해야 함.

// 잘못된 사용

public class Order {
    public void setStatus(boolean status) {
        this.status = status;
    }
}

public void orderService_cancelEvent() {
    order.setStatus(false);
}
// 올바른 사용

public class Order {
    public void cancelOrder() {
        this.status = false;
    }
}

public void orderService_cancelEvent() {
    order.cancelOrder();
}

 

그렇다면, Setter 가 없는 이 상황에서 어떻게 값을 채워 DB에 삽입해야 할까?

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메서드 호출하여 변경하는 것을 전제로 함.

여기서는 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스 사용.

생성자나 빌더나 생성 시점에 값을 채워주는 역할은 같음.

다만, 생성자의 경우 지금 채어야 할 필드가 무엇인지 명확히 지정할 수 없음.

 

다음을 통해 생성자의 문제점을 알아보자.

만약 개발자가 new Example(b, a)처럼 a와 b의 위치를 변경해도 코드를 실행하기 전까지 문제를 찾을 수 없음.

즉, 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 없음.

public Example(String a, String b) {
    this.a = a;
    this.b = b;
}

 

반면, 빌더 사용하면 다음과 같이 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있음.

Example.builder()
    .a(a)
    .b(b)
    .build();

 

Posts 클래스 생성이 끝나면, Posts 클래스로 Database 접근하게 해 줄 JpaRepository 생성. (인터페이스로 생성)

 

보통 쿼리 매퍼에서 Dao라고 불리는 DB Layer 접근자임. JPA에선 Repository라고 부르며 인터페이스 생성함.

단순히 아래처럼 인터페이스 생성 후 JpaRepository<Entity 클래스, PK 타입> 상속하면 기본적인 CRUD 메서드가 자동으로 생성.

@Repository 어노테이션 추가할 필요가 없는데, 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 함.

둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Repository 없이는 제대로 역할을 수행할 수 없음.

만약, 프로젝트 규모가 커져 도메인별로 프로젝트를 분리해야 한다면 Entity 클래스와 기본 Repository는 함께 움직여야 하므로 도메인 패키지에서 함께 관리하게 됨.

package com.jojoldu.book.springboot.domain.posts;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PostsRepository extends JpaRepository<Posts, Long> {
}