이전에 얘기했듯 웹 애플리케이션에서는 하나의 EntityManagerFactory를 생성하고, 사용자의 요청에 따라 EntityManager를 생성하여 트랜잭션을 처리한다. 영속성 컨텍스트(Persistence Context)는 애플리케이션 내에서 객체 인스턴스를 일시적으로 보관하고 관리하는 논리적 환경이다. JPA를 이해하는데 가장 중요한 단어이다.
영속성 컨텍스트는 쉽게 말해 가상의 데이터베이스 역할을 하는 논리적인 개념으로, 눈에 보이지 않는다. EntityManager가 생성될 때 같이 생성되며, 보통 하나의 트랜잭션 범위 동안 유지된다. 객체의 생명주기는 비영속(New/Transient), 영속(Managed), 준영속(Detached), 삭제(Removed)로 나뉜다.
비영속(New/Transient): 객체가 새로 생성만 되어 영속성 컨텍스트와 연관이 없는 상태
영속(Managed): persist, find 등으로 영속성 컨텍스트에서 관리되는 상태
준영속(Detached): 영속성 컨텍스트에서 분리된 상태
영속성 컨텍스트가 제공하는 기능을 사용 못하게 된다.
em.detach(객체), em.clear(), em.close()로 준영속 상태로 만들 수 있다.
각각 특정 객체만 전환, 전체 초기화, 종료의 동작을 한다.
삭제(Removed): 영속성 컨텍스트에서 삭제된 상태
영속성 컨텍스트의 장점(또는 주요 특징)으로는 1차 캐시, 동일성(Identity) 보장, 쓰기 지연(Write-behind), 변경 감지(Dirty Checking), 지연 로딩(Lazy Loading)이 있다.
1차 캐시: 컨텍스트 내부에 객체들이 캐시되어 있어, 동일한 객체를 여러번 조회해도 최초 한 번만 DB에서 읽고, 이후에는 영속성 컨텍스트에서 조회한다. 영속성 컨텍스트에 없는 객체를 find하면 DB에서 조회 후 1차 캐시에 저장한다.
동일성 보장: 영속성 컨텍스트 내에서 같은 PK를 가지는 객체는 항상 같인 객체로 식별된다.
쓰기 지연: persist() 나 객체 추가 작업들이 먼저 영속성 컨텍스트에 저장되고, 실제 DB에는 트랜잭션 커밋 시 일괄로 반영된다. 이때 쓰기 지연 SQL 저장소에 쿼리를 저장하고 있다가 한 번에 실행한다.
변경 감지: 트랜잭션 내에서 객체의 값이 변경되면, 커밋 시점에 자동으로 변경 내용을 탐지해 DB에 반영한다.
쓰기 지연에서 '트랜잭션 커밋 시 일괄로 반영된다'고 했는데, 이때 flush가 실제로 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다. 커밋 할 때 flush가 자동(jpql 쿼리 실행 시에도)으로 호출되며, em.flush()로 직접 호출할 수도 있다. flush는 이름과 다르게 영속성 컨텍스트를 비우는 것이 아니며, 객체는 계속 영속 상태를 유지한다. flush가 발샐하면 다음 단계로 진행된다.
변경 감지로 변경된 객체를 찾는다.
쓰기 지연 SQL 저장소에 변경 SQL(INSERT, UPDATE, DELETE)를 등록한다.
H2 데이터베이스는 기존에 사용하고 있었기에 신규 DB 파일만 생성하고 Java 프로젝트를 생성한다. 강의에서는 Java 8에 빌드도 Maven으로 설정하고 있으나, 깔린대로 해도 문제가 없을거 같아서 Java 21에 빌드는 Gradle로 설정했다.
plugins {
id("java")
}
group = "jpa-basic"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
//spring-boot-starter-jpa 를 보통 많이 쓴다고 하지만,
//강의에서는 maven 에 hibernate 와 h2 database 를 따로 추가했다.
implementation("org.hibernate.orm:hibernate-core:7.0.6.Final") //hibernate
implementation("com.h2database:h2:2.3.232") //h2 database
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks.test {
useJUnitPlatform()
}
JPA 구현체인 Hibernate와 사용 할 데이터베이스인 H2 데이터베이스의 의존성을 추가해준다. 스프링 부트였다면 spring-boot-starter-jpa만 추가해도 두 개 다 포함하고 있으나, 이번 강의는 스프링을 사용하지 않기 때문에 제외한다.
JPA 설정 파일
JPA를 사용하기 위해서는 persistence.xml이라는 설정 파일이 필요하다. 기본적으로 META-INF 디렉토리 하위에 위치하고, persistence-unit의 name으로 이름을 지정하여 사용한다. 속성 중에 javax.persistence로 시작하는 속성은 JPA 표준 속성으로 구현체의 영향을 받지 않고, hibernate로 시작하는 속성은 Hibernate에서만 사용할 수 있는 속성이다.
JPA의 Dialect(방언)은 JPA 구현체가 어떤 데이터베이스에 맞는 SQL을 생성해야 하는지 알려주는 설정이다. JPA는 특정 데이터베이스에 종속되어있지 않고, SQL은 표준이 있지만 DB마다 문법·함수·데이터타입 등이 다르기 때문에 필요하다. 위의 JPA 설정 파일에서도 hibernate.dialect 속성을 H2 데이터베이스에 맞추었다. 만약 MySQL이나 Oracle을 사용한다면 사용하는 데이터베이스에 맞추면 된다.
기본 코드 생성
package hellojpa;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityTransaction;
import jakarta.persistence.Persistence;
import java.util.List;
public class JpaMain {
public static void main(String[] args) {
//애플리케이션에 하나만 생성
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
//트랜잭션 필요 시 생성 및 close, 쓰레드 간 공유 X
EntityManager em = emf.createEntityManager();
//JPA 의 모든 데이터 변경은 트랜잭션 안에서 실행
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
/* Create
Member member = new Member();
member.setId(2L);
member.setName("2Name");
em.persist(member);
*/
/* Update
//JPA 를 통해서 객체를 가져오면 persist 할 필요가 없다.
Member findMember = em.find(Member.class, 1L);
findMember.setName("Name1");
*/
List<Member> result = em.createQuery("select m from Member as m", Member.class)
.getResultList();
for(Member member : result) {
System.out.println("member.name = " + member.getName());
}
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}
JpaMain.java를 생성하고 EntityManagerFactory와 EntityManager(em)를 생성하고, 생성한 em에서 트랜잭션을 얻어와 그 안에서 데이터 변경을 실행한다. 트랜잭션이 종료되면 결과에 따라 commit 또는 rollback하게 되고, em을 반환하게 된다. 주석으로 작성하였 듯이 EntityManagerFactory는 애플리케이션에 하나만 생성하고, em은 트랜잭션 필요 시 생성하고 반환하면 된다.
=> EntityManagerFactory는 데이터베이스 연결 설정, JPA 메타데이터 파싱, EntityManager 생성 등 무거운 리소스를 관리하는 역할이다. 생성 비용이 매우 크고, 내부적으로 커넥션 풀, 캐시, 설정 정보 등을 보유한다. 또한 여러 스레드에서 동시에 접근해도 안전하게 설계(Thread-safe)되어 있지만, 여러 개를 만들면 시스템 자원을 낭비하고, 성능 저하 및 예기치 않은 동시성 문제가 발생할 수 있어 애플리케이션에 하나만 생성하고 공유하는 것이 가장 효율적이다.
=> EntityManager는 실제로 Entity를 관리(CRUD)하며, 영속성 컨텍스트※를 관리한다. 생성 비용이 크지 않고, 가볍다. 하지만 여러 스레드에서 동시에 접근하면 데이터 불일치, 예기치 않은 버그가 발생할 수 있다(Non-thread-safe). EntityManager는 트랜잭션 범위 내에서 Entity의 상태를 관리하는데, 트랜잭션이 끝나면 EntityManager와 함께 영속성 컨텍스트도 종료되어야 일관성이 보장된다. 다음과 같은 이유로 필요 시 생성하고, 공유하지 않는다.
동시성 문제: 여러 트랜잭션이 하나의 `EntityManager`를 공유하면, 서로 다른 작업이 섞여 데이터 정합성이 깨진다.
영속성 컨텍스트 오염: 이전 트랜잭션의 데이터가 남아있어 잘못된 결과를 반환할 수 있다.
JPA 명세: `EntityManager`는 트랜잭션 단위로 생성/사용/종료하는 것이 명확하게 권장된다.
※ 영속성 컨텍스트(Persistence Context)는 JPA에서 Entity 객체를 영구적으로 저장하고 관리하는 메모리 공간을 의미하는 핵심 개념 중 하나이다. Entity 생명주기 관리, 1차 캐시, 동일성 보장, 변경 감지(Dirty Checking), 쓰기 지연(Write-behind), 지연 로딩(Lazy Loading) 등의 주요 특징이 있다.
package hellojpa;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Member {
@Id
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;
}
}
@Entity 어노테이션이 있어야 JPA가 관리하는 객체가 된다. 순수한 Java 프로젝트를 생성했기 때문에 persistence.xml에 <class>hellojpa.Member</class>와 같이 Entity 클래스를 등록해줘야 인식한다.
JPQL
JPQL(Java Persistence Query Language)는 JPA에서 사용하는 객체지향 쿼리 언어다. 가장 큰 특징은 데이터베이스의 테이블이 아니라 Entity 객체를 대상으로 쿼리를 작성한다는 점인데, 검색도 테이블이 아니라 Entity 객체를 대상으로 검색한다. SQL을 추상화하기 때문에 특정 데이터베이스에 종속되지 않으며, JPA가 JPQL을 실제 DB에 맞는 SQL로 변환해서 실행한다.
...
List<Member> result = em.createQuery("select m from Member as m", Member.class)
.getResultList();
...
JPA는 Java Persistence API의 약자로 자바 객체를 관계형 데이터베이스에 저장하는 방식을 표준화한 자바 명세이다. ORM을 위한 인터페이스와 어노테이션을 정의하며, 자바 클래스를 데이터베이스 테이블에 매핑하거나 SQL 대신 객체지향 코드로 데이터베이스 CRUD 작업을 수행한다. JPA 자체는 '명세'이고, 실제로는 Hibernate, EclipseLink 등과 같은 구현체를 통해 동작한다. 이런 JPA는 데이터베이스 접근을 단순화하고, 생산성을 높이며, 객체지향 자바 코드와 관계형 데이터베이스 간의 간극을 줄여준다.
public class Member {
private String memberId;
private String name;
private String tel; // 기존에 없던 필드가 추가되었을 때
...
}
JPA를 사용하지 않을 때는 tel이라는 필드가 추가되면 해당 테이블을 조회하는 모든 SQL을 찾아서 수정해야하지만, JPA를 사용하면 필드 추가 이후 SQL은 JPA가 알아서 생성해준다.
지연 로딩과 즉시 로딩
지연 로딩(Lazy Loading)은 연관된 엔티티를 실제로 사용하는 시점까지 데이터베이스에서 조회를 미루는 방식으로, Member 엔티티에서 Team 엔티티를 지연 로딩으로 설정하면, Member를 조회할 때는 Team 데이터가 실제로 필요할 때까지 쿼리가 실행되지 않는다. member.getTeam() 호출 등 실제로 Team 데이터가 필요해질 때 추가 쿼리가 실행된다.
=> 불필요한 데이터 조회를 방지해 성능을 향상시킬 수 있고, 메모리 사용량을 줄일 수 있다는 장점이 있다. 단점으로는 연관 객체를 사용하는 시점에 추가 쿼리가 발생하므로, 트랜잭션이 종료된 후 접근하면 LazyInitializationException이 발생할 수 있다.
즉시 로딩(Eager Loading)은 연관된 모든 엔티티도 함께 즉시 조회하는 방식으로, Member 엔티티에서 Team 엔티티를 즉시 로딩으로 설정하면, Member를 조회할 때 Team도 함께 조인 쿼리로 조회된다.
=> 연관 객체를 바로 사용할 수 있고, 트랜잭션 종료 후에도 이미 데이터가 로딩되어 있어 예외가 발생하지 않는다는 장점이 있다. 단점으로는 연관된 데이터가 많을 경우 불필요하게 많은 데이터를 한 번에 가져와 성능 저하 및 메모리 낭비가 발생할 수 있고, 복잡한 연관관계에서는 N+1 문제 등 예기치 않은 쿼리 폭증이 발생할 수 있다.
대부분에 실무에서는 지연 로딩 사용이 권장된다.
패러다임 불일치 해결
객체지향 프로그래밍과 관계형 데이터베이스 사이에는 패러다임의 불일치라는 근본적인 차이가 존재한다. 객체지향은 데이터를 객체와 메소드로 구성하며, 상속·다형성 등 계층적 구조를 지원한다. 관계형 데이터베이스는 데이터를 행과 열로 구조화하며, 상속이나 객체 참조 개념이 없다. 이러한 차이로 인해 다음과 같은 문제가 발생한다.
상속 미지원: 객체지향의 상속 구조를 테이블로 직접 표현할 수 없다
참조와 식별의 차이: 객체는 참조로 연결되지만, RDB는 외래키로 연결된다
데이터 구조의 불일치: 객체는 중첩, 컬렉션 등 다양한 구조를 가지지만, RDB는 단순한 테이블 구조이다
데이터 접근 방식의 차이: 객체는 관계를 따라 탐색하지만, RDB는 조인으로 데이터를 결합한다
SQL 대신 객체를 대상으로 하는 JPQL 등 객체지향 쿼리를 지원해 객체 모델에 맞는 데이터 접근이 가능하다
따라서 상속, 다형성, 컬렉션 등 객체지향 개념을 데이터베이스에 자연스럽게 반영하므로 반복적인 SQL 및 매핑 코드가 감소되어 개발 생산성이 향상되고, 객체지향적 설계와 데이터베이스 설계의 간극이 해소된다. 또한 데이터베이스 벤더에 종속되지 않는 이식성을 확보할 수 있는 효과가 있다.
스프링 빈 스코프(Bean Scope)는 스프링 컨테이너가 관리하는 객체인 빈이 생성되고 소멸될 때까지 존재할 수 있는 범위나 생명주기이다. 주요 스코프 종류는 다음과 같다.
싱글톤(Singleton) : 기본값으로 스프링 컨테이너 시작부터 종료까지 단 하나의 인스턴스만 생성, 모든 요청에 동일 객체를 반환
프로토타입(Prototype) : 빈을 요청할 때마다 새로운 인스턴스 생성. 생성과 의존성 주입까지만 컨테이너가 관리하며 이후는 클라이언트가 직접 관리 필요
request : HTTP 요청마다 새로운 빈 생성, 요청이 끝나면 소멸한다. 주로 웹 애플리케이션에서 사용
session : HTTP 세션마다 새로운 빈 생성, 세션 종료 시 소멸한다. 주로 사용자별 데이터 관리에 사용
application : 서블릿 컨텍스트와 동일한 생명주기를 가지며, 웹 애플리케이션 전체에서 공유
websocket : 웹소켓 연결마다 빈 생성, 연결 종료 시 소멸한다.
프로토타입 스코프
싱글톤 스코프의 빈을 스프링 컨테이너에 요청한다.
스프링 컨테이너는 본인이 관리하는 스프링 빈을 반환한다. (3개의 클라이언트 동일)
프로토타입 스코프의 빈을 스프링 컨테이너에 요청한다.
스프링 컨테이너는 프로토타입 빈을 생성하고, 필요한 의존관계를 주입한다.
스프링 컨테이너는 생성한 프로토타입 빈을 클라이언트에 반환한다.
이후에 빈을 관리하지 않으며, 3개의 클라이언트는 다른 빈을 받는다.
싱글톤 스코프 빈 테스트
package spring.basic.scope;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;
import static org.assertj.core.api.Assertions.*;
public class SingletonTest {
@Test
void singletonBeanFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SingletonBean.class);
SingletonBean singletonBean1 = ac.getBean(SingletonBean.class); //최초 호출 시 init 실행
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
System.out.println("singletonBean1 = " + singletonBean1);
System.out.println("singletonBean2 = " + singletonBean2);
assertThat(singletonBean1).isSameAs(singletonBean2); //둘은 같은 객체이다
ac.close(); //종료 시 destroy 실행
}
@Scope("singleton") //기본 Scope 는 singleton 이다
static class SingletonBean {
@PostConstruct
public void init() {
System.out.println("SingletonBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("SingletonBean.destroy");
}
}
}
프로토타입 스코프 빈 테스트
package spring.basic.scope;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;
import static org.assertj.core.api.Assertions.assertThat;
public class PrototypeTest {
@Test
void prototypeBeanFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
System.out.println("find prototypeBean1");
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class); //호출 시 init 실행
System.out.println("find prototypeBean2");
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class); //호출 시 init 실행
System.out.println("prototypeBean1 = " + prototypeBean1);
System.out.println("prototypeBean2 = " + prototypeBean2);
assertThat(prototypeBean1).isNotSameAs(prototypeBean2); //둘은 다른 객체이다
ac.close(); //종료 시 destroy 를 실행하지 않는다
}
@Scope("prototype") //Scope 를 prototype 으로 변경
static class PrototypeBean {
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
}
프로토타입과 싱글톤을 함께 사용할 때 문제점
일반적으로 프로토타입 스코프를 사용하는 이유는 클라이언트가 요청할 때마다 인스턴스를 생성하기 위해서일 것이다. 하지만 스프링은 싱글톤 빈의 의존성 주입을 생성할 때 한 번만 주입한다. 따라서 싱글톤 빈에 프로토타입 스코프를 가진 빈을 주입 받아서 사용할 경우 주입 시점에 프로토타입 빈이 생성되어 주입되며 이후에 같은 인스턴스를 사용하게 된다(프로토타입 빈의 의미가 사라진다). 이 문제점을 해결하기 위해서는 ObjectFactory 또는 Provider를 사용할 수 있다.
package spring.basic.scope;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Provider;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public class SingletonWithPrototypeTest1 {
@Test
void prototypeFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
prototypeBean1.addCount();
assertThat(prototypeBean1.getCount()).isEqualTo(1);
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
prototypeBean2.addCount();
assertThat(prototypeBean2.getCount()).isEqualTo(1);
}
@Test
void singletonClientUsePrototype() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
ClientBean clientBean1 = ac.getBean(ClientBean.class);
int count1 = clientBean1.logic();
assertThat(count1).isEqualTo(1);
ClientBean clientBean2 = ac.getBean(ClientBean.class);
int count2 = clientBean2.logic();
assertThat(count2).isEqualTo(1);
}
@Scope("singleton")
static class ClientBean {
@Autowired
private Provider<PrototypeBean> provider;
public int logic() {
PrototypeBean prototypeBean = provider.get();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
}
@Scope("prototype")
static class PrototypeBean {
private int count = 0;
public void addCount() {
count++;
}
public int getCount() {
return count;
}
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init " + this);
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
}
웹 스코프
웹 스코프는 웹 환경에만 동작하며, 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리한다. 종류는 위에서 말했던 것 중 request, session, application, websocket이 있다. 이번에는 request 스코프를 통해 로그를 남기는 예제를 작성했다.
@Scope(value = "request")로 request 스코프로 지정하였기 때문에 이 빈은 HTTP 요청 당 하나의 인스턴스가 생성되고, HTTP 요청 종료 시 소멸된다. 빈 생성 시 @PostConstructor를 통해 UUID를 만들어서 저장하고, 빈 소멸 시 @PreDestroy를 통해 소멸 메시지를 남긴다. requestURL은 빈 생성 당시에는 알 수 없기 때문에 setter로 입력받는다.
MyLogger가 제대로 동작하는지 확인하기 위한 컨트롤러로, log-demo로 요청한다.
LogDemoService
package spring.basic.web;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import spring.basic.common.MyLogger;
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger;
public void logic(String id) {
// MyLogger myLogger = myLoggerProvider.getObject();
myLogger.log("service id = " + id);
}
}
요청에 따른 로그가 정상적으로 출력된다.
MyLogger에서 @Scope의 proxyMode를 ScopedProxyMode.TARGET_CLASS를 설정해놨는데, 이는 MyLogger를 프록시 객체로 생성하고, 내부에서는 진짜 myLogger를 호출하게 된다. 이전에 나왔던 프록시 처럼 CGLIB을 통해 처리된다. Provider나 프록시나 진짜 객체 조회를 필요한 시점까지 지연처리 한다는 점이 중요하다.
아파치를 Docker로 돌리고 싶지만 사정상 서버에 직접 설치해서 운영 중이다. 이번에 다른 목적으로 WEB 서버 6대가 추가되었는데, 6개의 콘솔을 열어서 설정파일을 수정하고 재기동하는건 말도 안 되게 귀찮기 때문에 Bash 스크립트를 작성했다.
ssh 키 쌍 생성하여 패스워드 입력 없이 서버 연결 설정
# 1.키 쌍 생성
$ ssh-keygen -t rsa
# Enter file in which to save the key : 엔터 (기본 경로 ~/.ssh 사용)
# Enter passphrase : 엔터 (패스워드 없이 사용)
# Enter same passphrase again : 엔터
# 2. 생성된 공개키를 대상 서버에 복사
$ ssh-copy -id -p 22 username@remote.server.ip
# 3. 연결 확인
$ ssh -p 22 username@remote.server.ip
ssh 연결하여 명령 실행
# 한 개의 명령 실행
$ ssh -p 22 username@remote.server.ip "touch test"
# 여러 개의 명령 실행(세미콜론으로 구분)
$ ssh -p 22 username@remote.server.ip "touch test; mv test tset"
스크립트 작성
설정 파일의 수정이 있을 때 파일명.YYYYMMDD 형태로 파일을 백업한다.
conf.d의 ssl.conf, vhost.conf, workers.properties 위주로 수정한다.
스크립트 작성의 전제 조건이다.
#!/bin/bash
APACHE_CONFD_PATH=/etc/httpd/conf.d
DATE=`date +%Y%m%d`
# 오늘 날짜로 수정된 파일명
MODIFIED_CONF_LIST=`ls -1 ${APACHE_CONFD_PATH} | grep ${DATE} | sed 's/\.[^.]*$//`
# 대상 서버 목록
SERVERS="192.168.0.2 192.168.0.3 192.168.0.4 192.168.0.5 192.168.0.6"
# 수정된 파일별로 반복
for CONF in ${MODIEFIED_CONF_LIST}
do
# 대상 서버별로 파일 전송
for SERVER in ${SERVERS}
do
scp -P 22 ${APACHE_CONFD_PATH}/${CONF} apache@${SERVER}:${APACHE_CONFD_PATH}/${CONF}
scp -P 22 ${APACHE_CONFD_PATH}/${CONF}.${DATE} apache@${SERVER}:${APACHE_CONFD_PATH}/${CONF}.${DATE}
done
done
# 아파치 재기동
for SERVER in ${SERVERS}
do
ssh -n -p 22 apache@${SERVER} "sudo /home/apache/apache_restart.sh"
sleep 5
done
sudo /home/apache/apache_restart.sh
기존에는 apache_stop.sh과 apache_start.sh를 따로 실행해서 재기동을 했으나, 한 번의 명령으로 재기동 하기 위해 각 서버에 apache_restart.sh를 추가 작성했다.
#!/bin/bash
# 아파치 종료
sh /home/apache/apache_stop.sh
while true
do
# 프로세스에 httpd가 없으면(내가 실행한 grep 하나만 나오면) 탈출
if [ `ps -ef | grep -c httpd` -eq 1 ]
then
break
fi
sleep 1
done
# 아파치 시작
sh /home/apache/apache_start.sh
스프링 컨테이너 생성 → 스프링 빈 생성 → 의존관계 주입 → 초기화 콜백 → 빈 사용 → 소멸 전 콜백 →스프링 종료
콜백(Callback)은 특정 시점에 시스템이 자동으로 호출하는 메소드다. 스프링에서는 빈을 초기화 하거나 소멸 시점에 개발자가 원하는 작업을 할 수 있도록 콜백 메소드를 제공한다. 초기화는 초기 세팅이나 DB 같은 외부 리소스 연결 등에 사용하고, 소멸은 연결 해제 등에 사용한다.
public class NetworkClient {
private String url;
public NetworkClient() {
System.out.println("생성자 호출, url = " + url);
connect();
call("초기화 연결 메시지");
}
public void setUrl(String url) {
this.url = url;
}
//서비스 시작시 호출
public void connect() {
System.out.println("connect: " + url);
}
public void call(String message) {
System.out.println("call: " + url + " message = " + message);
}
//서비스 종료시 호출
public void disconnect() {
System.out.println("close: " + url);
}
}
테스트 코드를 작성한다. NetworkClient는 생성되면서 connect로 연결하고, 소멸 시 disconnect로 해제한다.
public class BeanLifeCycleTest {
@Test
public void lifeCycleTest() {
ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
NetworkClient client = ac.getBean(NetworkClient.class);
ac.close();
}
@Configuration
static class LifeCycleConfig {
@Bean
public NetworkClient networkClient() {
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("https://blog.raphong.com");
return networkClient;
}
}
}
NetworkClinet를 빈으로 등록하면서 생성자 호출 이후 setter를 통해 url을 초기화하도록 했다. 해당 코드를 실행하면 생성자에서 출력하는 3줄이 나오는데, url 변수가 전부 null로 출력된다. 이유는 생성자 호출 이후에 setter를 호출하기 때문이다.
빈 생명주기 콜백의 종류 및 구현 방법은 대표적으로 세가지가 있다.
인터페이스 구현
public class NetworkClient implements InitializingBean, DisposableBean {
...
public NetworkClient() {
System.out.println("생성자 호출, url = " + url);
}
...
@Override //다음부터 init으로 변경
public void afterPropertiesSet() throws Exception {
connect();
call("초기화 연결 메시지");
}
@Override //다음부터 close로 변경
public void destroy() throws Exception {
disConnect();
}
}
InitializingBean을 상속하면서 afterPropertiesSet() 메소드에 초기화 작업을 구현한다.
원래 생성자에 있던 connect와 call을 해당 메소드로 옮겼다.
DisposableBean을 상속하면서 destroy() 메소드에 소멸 작업을 구현한다.
disconnect 메소드를 작성한다.
실행 시 문제 없이 생성자 출력 내용 → 초기화 출력 내용 → 소멸 출력 내용이 나오지만, 해당 방식은 스프링에 의존적이기 때문에 스프링에서만 사용할 수 있고, afterPropertiesSet() 및 destroy() 메소드의 이름을 변경할 수도 없다. 초창기에 사용하던 방식으로 지금은 거의 사용하지 않는다.
@Bean 어노테이션의 속성
public class BeanLifeCycleTest {
...
@Configuration
static class LifeCycleConfig {
@Bean(initMethod = "init", destroyMethod = "close")
public NetworkClient networkClient() {
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("https://blog.raphong.com");
return networkClient;
}
}
}
@Bean 어노테이션의 속성인 initMethod와 destroyMethod로 생성과 소멸에 사용할 콜백을 지정할 수 있다. 해당 방법은 외부 라이브러리에도 적용 가능하며, 메소드명을 자유롭게 지정할 수 있다. 또한 destroyMethod의 경우 기본적으로 '추론(inferred)'이 들어가게 되는데, 대부분의 라이브러리에서 사용하는 close 또는 shutdown 메소드를 자동으로 호출해준다.
어노테이션
public class NetworkClient {
...
@PostConstruct
public void init() {
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
@PreDestroy
public void close() {
System.out.println("NetworkClient.close");
disConnect();
}
}
@PostConstruct 어노테이션을 붙인 메소드는 의존관계 주입이 끝난 후 호출된다.
@PreDestroy 어노테이션을 붙인 메소드는 빈 소멸 직전에 호출된다.
어노테이션을 붙이면 자동으로 처리되며, 이 방식은 자바 표준(Jakarta EE, JSR-250)으로 스프링에 종속적이지 않아 가장 권장하는 방법이다. 외부 라이브러리에는 사용할 수 없다는 단점이 있다.
@Autowired(required=false) : 주입할 대상이 없으면 수정자 메소드 호출하지 않음
@Nullable : 주입할 대상이 없으면 null이 됨
Optional<> : 주입할 대상이 없으면 Optional.empty가 됨
static class TestBean {
//호출 X
@Autowired(required = false)
public void setNoBean1(Member noBean1) {
System.out.println("noBean1 = " + noBean1);
}
@Autowired
public void setNoBean2(@Nullable Member noBean2) {
System.out.println("noBean2 = " + noBean2);
}
@Autowired
public void setNoBean3(Optional<Member> noBean3) {
System.out.println("noBean3 = " + noBean3);
}
}
테스트 클래스를 작성해서 실행하면 noBean2와 3은 출력되지만, 1은 호출도 하지 않기 때문에 출력이 없다.
위에서 생성자 주입을 가장 권장한다고 했는데, 권장하는 이유는 여러가지가 있다.
불변성 보장 : final 선언 및 생성자 1회 호출로 불변 객체 설계 가능
의존성 누락/실수 방지 : 컴파일 시 의존성 누락 바로 확인 가능
순환 참조 방지 : 애플리케이션 구동 시점에 순환 참조 오류 감지
테스트 용이성 : DI 컨테이너 없이도 테스트 가능
코드 명확성, 유지보수성 향상 : 의존성 명확, 롬복 등과 결합 가능
롬복(Lombok)
롬복은(Lombok)은 자바에서 반복적으로 생성해야 하는 코드(getter, setter, 생성자 등)를 자동으로 생성해주는 오픈소스 라이브러리이다. 어노테이션 기반으로 동작하며, 개발자가 클래스에 특정 Lombok 어노테이션을 붙이면 컴파일 과정에서 해당 메소드들이 자동으로 생성된다.
롬복을 사용하기 위해서는 우선 IntelliJ 설정에서 플러그인을 설치하고 추가 설정을 해준다.
@Component
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
}
기본 코드이다. 생성자와 @Autowired 어노테이션이 있다.
@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService{
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
}
@RequiredArgsConstructor 어노테이션을 클래스에 추가하고, 생성자를 제거한다. 추가한 어노테이션의 이름처럼 final 필드들의 생성자를 자동으로 생성해준다.
코드에는 보이지 않지만 생성된걸 확인할 수 있다.
조회된 빈이 2개 이상일때 주입 방법
@Autowired는 타입으로 빈을 조회한다. 그래서 전에 배웠던 내용처럼 조회된 빈이 2개 이상이면 NoUniqueBeanDefinitionException이 발생한다. 하위 타입(구현체)를 지정해서 해결할 수 있으나 DIP 위반이므로 적절하지 않다.
같이 필드명을 빈 이름으로 변경하면 fixDiscountPolicy와의 충돌 없이 rateDiscountPolicy가 주입된다. 필드명 매칭은 먼저 타입으로 매칭을 시도하고, 그 결과 빈이 2개 이상일 때 추가로 동작하는 기능으로 (1) 타입 매칭 → (2) 필드명, 파라미터명으로 빈 이름 매칭과 같이 동작한다.
@Qualifier 사용
@Qualifer 어노테이션은 추가 구분자를 붙여주는 방법으로, 빈 이름을 변경하는 것은 아니다.
@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy { ... }
앞선 @Qualifier("mainDiscountPolicy")만 사용하면 컴파일 시 타입 체크가 안 되고, 무엇보다 조금 길게 느껴질 수 있다. 그래서 이런식으로 @MainDiscountPolicy 어노테이션을 만들면 @Qualifier("mainDiscountPolicy")를 다는 것과 동일한 역할을 한다.
둘 다 쓰기
AllBeanTest.java
package spring.basic.autowired;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import spring.basic.AutoAppConfig;
import spring.basic.discount.DiscountPolicy;
import spring.basic.member.Grade;
import spring.basic.member.Member;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
public class AllBeanTest {
@Test
void findAllBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);
DiscountService discountService = ac.getBean(DiscountService.class);
Member member = new Member(1L, "userA", Grade.VIP);
int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");
assertThat(discountService).isInstanceOf(DiscountService.class);
assertThat(discountPrice).isEqualTo(1000);
int rateDiscountPrice = discountService.discount(member, 20000, "rateDiscountPolicy");
assertThat(rateDiscountPrice).isEqualTo(2000);
}
static class DiscountService {
private final Map<String, DiscountPolicy> policyMap;
private final List<DiscountPolicy> policies;
public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
this.policyMap = policyMap;
this.policies = policies;
System.out.println("policyMap = " + policyMap);
System.out.println("policies = " + policies);
}
public int discount(Member member, int price, String discountCode) {
DiscountPolicy discountPolicy = policyMap.get(discountCode);
return discountPolicy.discount(member, price);
}
}
}
DiscountService는 Map으로 모든 DiscountPolicy를 주입받고, discount 메소드는 discountCode에 따라 매칭되는 빈을 사용하는 식으로 동작한다. Map에는 키(String)에 빈 이름이, 값으로 DiscountPolicy 타입으로 조회한 모든 빈이 들어간다. List는 빈만 들어간다.