JPA는 Java Persistence API의 약자다. IT쪽에서 Persistence는 주로 영속성이라는 단어로 해석이된다. Persistence는 Application에서 Java 객체가 Application이 종료된 이후에도 계속 유지되는 메커니즘을 의미하기때문에 Persistence라는 단어를 포함하여 JPA라고 통칭한다.
JPA가 어떤 tool이나 framework는 아니다. 혼자서 작동할 수도 없기 때문에 반드시 구현체가 필요하다.
JPA는 Java의 POJO 객체가 RDB에 매핑되는 방식을 Annotaion 또는 xml방식으로 관계형 매핑을 정의할 수 있게 해준다.
Repository Interface
Spring JPA에서 Repository는 가장 핵심적인 인터페이스가 되는데 도메인 class와 도메인의 id 타입을 제네릭 타입인터페이스로 받는다
public interface CrudRepository<T, ID extends Serializable> extends Repository<T, ID> {
<S extends T> S save(S entity);
T findOne(ID primaryKey);
Iterable<T> findAll();
Long count();
void delete(T entity);
boolean exists(ID primaryKey);
// … more functionality omitted.
}
위에서 볼 수 있는 CrudRepository는 Repository를 상속받은 인터페이스로써 Crud 기능을 제공해준다
CrudRepository 이외에도 PagingAndSortingRepository, ReacitiveCrudRepository 등등이 있는데 전부 Repository 인터페이스를 상속받는다
org.springframework.data.repository
Interface Repository<T,ID>
Type Parameters:
T- the domain type the repository manages
ID- the type of the id of the entity the repository manages
Central repository marker interface. Captures the domain type to manage as well as the domain type’s id type. General purpose is to hold type information as well as being able to discover interfaces that extend this one during classpath scanning for easy Spring bean creation.
Domain repositories extending this interface can selectively expose CRUD methods by simply declaring methods of the same signature as those declared inCrudRepository.
Repository 인터페이스를 까보면 그냥 marker 인터페이스 역할만 해주고 Spring에서 제공하는 CrudRepository, PagingAndSortingRepository 이외에 따로 필요한 인터페이스가 있으면 Repository 인터페이스를 상속받는 인터페이스를 새로 작성하면 된다.
이제 CustomRepository를 만들어서 메서드명을 실제 db에 엑세스하는 쿼리로 만드는 부분이다.
public interface PersonRepository extends Repository<User, Long> {
List<Person> findByEmailAddressAndLastname(EmailAddress emailAddress, String lastname);
// Enables the distinct flag for the query
List<Person> findDistinctPeopleByLastnameOrFirstname(String lastname, String firstname);
List<Person> findPeopleDistinctByLastnameOrFirstname(String lastname, String firstname);
// Enabling ignoring case for an individual property
List<Person> findByLastnameIgnoreCase(String lastname);
// Enabling ignoring case for all suitable properties
List<Person> findByLastnameAndFirstnameAllIgnoreCase(String lastname, String firstname);
// Enabling static ORDER BY for a query
List<Person> findByLastnameOrderByFirstnameAsc(String lastname);
List<Person> findByLastnameOrderByFirstnameDesc(String lastname);
}
쿼리 빌더 메커니즘은 findBy .. readBy 등등의 접두어들을 떼어내고 나머지 부분을 파싱한다. (사실 findBy … 등등 OOOBy로 시작하는 접두어를 jpa에서 사용하는지 처음알았다 .. 쩝)
And나 Or, OrderBy, IgnoreCase 등등을 메서드명에서 직접 설정할 수 있다.
… where x.firstname like ?1(parameter bound with appended%)
EndingWith
findByFirstnameEndingWith
… where x.firstname like ?1(parameter bound with prepended%)
Containing
findByFirstnameContaining
… where x.firstname like ?1(parameter bound wrapped in%)
OrderBy
findByAgeOrderByLastnameDesc
… where x.age = ?1 order by x.lastname desc
Not
findByLastnameNot
… where x.lastname <> ?1
In
findByAgeIn(Collection<Age> ages)
… where x.age in ?1
NotIn
findByAgeNotIn(Collection<Age> ages)
… where x.age not in ?1
True
findByActiveTrue()
… where x.active = true
False
findByActiveFalse()
… where x.active = false
IgnoreCase
findByFirstnameIgnoreCase
… where UPPER(x.firstame) = UPPER(?1)
Repository에 선언되는 메서드 명이 jpa에서는 굉장히 중요한 역할을 하는데 자세한 알고리즘은 직접 찾아보는게 좋을 듯 하고 underscore(_) 와 camel기법을 지원하는데 나는 java진영이니까 camel표기법을 사용해야한다.
메서드 명 이외에 직접 쿼리를 작성하는것도 다음과 같이 가능하다
@Entity
@NamedQuery(name = "User.findByEmailAddress",
query = "select u from User u where u.emailAddress = ?1")
public class User {
}
도메인 클래스와 메서드명의 구분을 위하여 .를 사용하여 이름을 정해주고 매핑될 메서드에 query 필드로 실제 쿼리를 작성해준다.
@Query
메서드명, 도메인클래스에 @NamedQuery를 작성하지 않고 Repository에 직접 @Query를 사용하여 실제 호출 될 쿼리를 작성하는 방법도 있다. (이게 가장 많이 사용하는 방식인듯)
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u where u.firstname like %?1")
List<User> findByFirstnameEndsWith(String firstname);
}
사실 위에서 봤던 메서드명으로 db에 엑세스하는 방법은 생각보다 메서드명 짜기가 까다로울꺼같아서 걱정됐는데 @Query를 사용하면 굉장히 편리하게 사용할 듯 하다!!
위에서
@Query(“select u from User u where u.firstname like %?1”) 는 사실 우리가 평소에 쓰던 쿼리랑 형태가 조금 다른데 다음과 같이 해석한다.
변수사용설명
entityName
select x from #{#entityName} x
주어진 repository에 관련된 도메인 타입의entityName를 삽입하세요.entityName는 다음과 같이 해석됩니다 : 만약@Entity어노테이션에서 도메인 타입 이름을 정한다면 , 그것은 사용될 것입니다. 그렇지 않으면 도메인 타입의 간단한 class-name이 사용될 것입니다. (역주: 그냥 뒤의 예제들을 조금 살펴보자^^; )
이 방법이 맘에 들지 않으면 다음과 같이 사용한다
public interface UserRepository extends JpaRepository<User, Long> {
@Query(value = "SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?0", nativeQuery = true)
User findByEmailAddress(String emailAddress);
}
@Query 어노테이션의 nativeQuery 옵션을 true로 변경해주면 실제 db에 액세스하는 쿼리처럼 사용 할 수 있다.
추가적으로 파라미터에 대한 매핑도
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u where u.firstname = :firstname or u.lastname = :lastname")
User findByLastnameOrFirstname(@Param("lastname") String lastname,
@Param("firstname") String firstname);
}
@Param 어노테이션을 통해서 사용 가능하다.
Annotation
jpa에서 사용하는 annotation들을 간단하게 정리해본다.
@Entity
domain클래스에 주로 선언되며 이 domain객체가 entity객체임을 지정하며 테이블과 직접 매핑되는 역할을 해준다.
@Table
@Entitiy 객체와 매핑할 db table을 지정해준다 name속성이 실제 table과 매칭되는 부분이고 만약 entity 클래스와 table 명이 다르다면 name속성에 실제 table 이름을 써주면 된다.
@Column
@Table과 마찬가지로 매핑할 컬럼명이 실제 액세스할 db table의 컬럼명과 다르다면 name속성으로 지정해서 사용할 수 있다. nullable 과 unique 속성도 사용 가능하다.
@Enumerated
java의 enum타입을 컬럼에 매핑할때 사용한다 value 속성은 EnumType.ORDINAL (enum 순서를 값으로 db에 저장), EnumType.STRING(enum 이름을 값으로 db에 저장) 이 있고 default는 EnumType.ORDINAL이다.
@Temporal
java의 java.util.Date 또는 java.util.Calendar 타입을 컬럼에 매핑할때 사용한다. TemporalType.DATE TemporalType.TIME TemporalType.TIMESTAMP가 있다 default 값이 없으므로 반드시 한개를 지정해줘야 한다.
@Lob
db에서 BLOB, CLOB, TEXT 타입과 매핑된다.
@Transient
@Transient가 지정된 필드는 매핑하지 않는다. 객체에 임시로 어떤 값을 보관하고 싶을 때 사용한다.
@DynamicUpdate
수정된 데이터만 동적으로 Update 해준다.
@DynamicInsert
데이터를 저장할 때 entity 객체에 존재하는 필드만으로 Insert SQL을 동적으로 생성해준다
@SequenceGenerator
Identity 전략 중 Sequence를 사용하기위해 Sequence를 설정 및 생성한다. 식별자 필드인 name은 필수값으로 설정되고 sequenceName, initialValue 등의 필드가 있다.
@Transient
@Transient가 지정된 필드는 매핑하지 않는다. 객체에 임시로 어떤 값을 보관하고 싶을 때 사용한다.
@DynamicUpdate
수정된 데이터만 동적으로 Update 해준다.
@DynamicInsert
데이터를 저장할 때 entity 객체에 존재하는 필드만으로 Insert SQL을 동적으로 생성해준다
@SequenceGenerator
Identity 전략 중 Sequence를 사용하기위해 Sequence를 설정 및 생성한다. 식별자 필드인 name은 필수값으로 설정되고 sequenceName, initialValue 등의 필드가 있다.
@TableGenerator
Identity 전략 중 테이블을 사용하기 위해 시퀀스 테이블을 설정 및 생성한다. 식별자 필드인 name은 필수값으로 설정되고 table, pkColumnName, valueColumnName 등의 필드가 있다.
@ManyToOne
테이블 연관관계를 매핑할 때 다중성을 나타내는 설정값으로 이용되고 N:1의 관계를 매핑할 때 설정한다. 필드로는 optional, fetch, cascade가 있다.
@OneToMany
@ManyToOne과는 반대로 1:N의 관계를 매핑할 때 사용한다. 속성은 @ManyToOne과 동일하다.
@OneToMany
마찬가지로 1:1의 관계를 나타내고 속성은 @ManyToOne, @OneToMany와 동일하다.
@JoinColum
테이블의 연관관계를 매핑할때 사용되며 name필드는 foreign key의 이름으로 동작한다. 기본값은 필드명 + ‘_’ + 컬럼명이고 필드는 referencedColumnName, foreignKey 가 있다.
Example (Spring Boot 2.13)
실제 연습해보는 시간이다. 간단하게 시나리오 부터 설정하면 Entity가 될 User.class는 id, password, name, phone(전화번호) 정도를 받는다. 회원가입(validation은 제외) 기능을 제공하고 db에 데이터가 들어가면 이름으로 찾기, name으로 찾기, phone으로 찾기, 전체 유저목록 나타내기를 전부 jpa로 구현하는 예제다.
db도 mysql 등등 따로 들어가면 더더욱 좋겠지만 가볍게 하기 위해서 h2 db를 사용한다. h2에 대한 자세한 내용은여기를 확인해보자
예제에서 비지니스로직은 tdd로 구현하고 실제 테스트는 간단한 폼화면을 붙여서 테스트해보도록 하겠다!!
먼저 이번 예제에서 사용할 lib를 먼저 셋팅한다. 나는 maven을 좋아하기때문에 pom.xml의 dependencies를 다음과 같이 적용한다.
나는 실제 테스트에서 jsp를 사용할 것이므로 javax.servlet과 org.apache.tomcat.embed 따로 추가해 주었다 h2같은경우는 이미 spring boot jpa 모듈에서 버전관리를 해주고 있으니 따로 버전 명시는 필요 없다.
추가로 h2 db 설정이 끝났다면 console에서 다음 스크립트를 실행한다
- user table script
CREATE TABLE USER (
id varchar2(10) primary key,
password varchar2(200) not null,
name varchar2(10) not null,
phone varchar2(14) ,
email varchar2(30)
)
이제부터 본격적으로 jpa에 연관된 부분이다 먼저 db와 같은방식으로 entity가 될 User이라는 도메인 객체를 생성한다.
lombok의 @Getter, @Setter, @ToString과 함께 빌더패턴을 사용하기 위해 @Builder도 domain클래스에 선언해줬다 그리고 가장 중요한 @Entity 를 적용해서 User.class가 Entity 객체임을 명시해준다. @AllArgsConstructor 는 User.class의 필드를 전부 매개변수로 받는 생성자고 @NoArgsConstructor는 기본생성자다 @NoArgsConstructor 인 기본생성자가 Entity클래스에 없으면 Jpa에서 기본생성자가 없다고 에러가나니 조심하자
이제 실제 화면에서 넘어온 User 객체를 db에 저장하는 비지니스 로직을 작성한다. UserRepository interface는 Jpa에서 제공하는 CrudRepository를 사용하여 기본적인 Crud기능에 이름으로 찾기, email로 찾기 등의 기능을 확장한다.
- UserRepository.interface
package com.example.demo.repositories;
import org.springframework.data.repository.CrudRepository;
import com.example.demo.model.User;
public interface UserRepository extends CrudRepository<User, Long> {
User findByName(String name);
User findByPhone(String phone);
User findByEmail(String email);
}
각각 이름, 전화번호, email로 User Entity를 db에서 꺼내오는 부분이다.
간단하게 테스트해보자
- UserRepositoryTest.class
package com.example.demo.repositories;
import static org.junit.Assert.assertEquals;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import com.example.demo.model.User;
@SpringBootTest
@RunWith(SpringRunner.class)
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Before
public void test_유저를_db에_넣기() {
User user = User.builder()
.id("sup2is")
.name("sup2")
.password("qwer!23")
.phone("010-0000-0000")
.email("dev.sup2is@gmail.com")
.build();
//when
userRepository.save(user);
}
}
먼저 테스트 데이터를 위한 User Object를 위해 @Before로 더미데이터를 셋업한다. 우리는 사실 userRepository.save() 메서드를 작성한 적이 없지만 상속받은 CrudRepository에 save() 이외에도 여러가지 메서드가 있으니 반드시 document를 확인하는게 좋을 것 같다.
findByName(), findByPhone, findByEmail을 테스트한다.
- UserRepositoryTest.class
@Test
public void test_유저를_db에_저장후_name값으로_찾기() {
//given
//when
User dbUser = userRepository.findByName("sup2");
//then
assertEquals("sup2is", dbUser.getId());
System.out.println(dbUser.toString());
}
@Test
public void test_유저를_db에_저장후_phone값으로_찾기() {
//given
//when
User dbUser = userRepository.findByPhone("010-0000-0000");
//then
assertEquals("sup2is", dbUser.getId());
System.out.println(dbUser.toString());
}
@Test
public void test_유저를_db에_저장후_email값으로_찾기() {
//given
//when
User dbUser = userRepository.findByEmail("dev.sup2is@gmail.com");
//then
assertEquals("sup2is", dbUser.getId());
System.out.println(dbUser.toString());
}
결과는 성공으로 떨어진다. 재미있는 사실은 우리는 UserRepository를 따로 Bean으로 등록하지 않았는데 @Autowired로 bean주입을 받는다. jpa에서 제공하는 Repository interface를 상속받으면 Spring bean으로 알아서 올려준다 마치 @Service, @Repository를 사용한 것과 동일하다. (legacy는 따로 jpa:repositories base-package 를 설정해주는듯? boot는 그냥 적용됨 ..)
너무 String 값만 불러오는 느낌이 있어서 gmail, naver 등의 사이트로 분리해서 User를 찾아오는 메서드도 하나 만들어봤다.
- UserRepository.interface
List<User> findByEmailContaining(String site);
@Query("select u from User u where u.email like %:site%")
List<User> findByEmailContaining(@Param("site") String site);
같은 메서드지만 하나는 Jpa에서 제공하는 “Containing”을 사용한 검색을 했고 나머지 하나는 네이티브 쿼리로 날려봤다
- UserRepositoryTest.class
@Test
public void test_유저의_email을_site별로_검색하기() {
//given
User user = User.builder()
.id("chlcc")
.name("chlcc")
.password("qwer!23")
.phone("010-1111-1111")
.email("dev.sup2is@naver.com")
.build();
userRepository.save(user);
//when
List<User> users = userRepository.findByEmailContaining("naver");
//then
assertEquals(1, users.size());
System.out.println(users.toString());
}
결과는 성공
사실 화면을 붙여서 실제 db에 insert되고 select하는거까지 테스트하려고했으나 … 너무 길어질꺼같아서 그냥 controller에서 api형식으로 불러들이는 방법으로 예제를 테스트해보겠다 !!
postman을 사용하여 controller에 access한다. 그냥 간단하게 /api/add 로 요청시 db에 넘어온 user 객체가 들어가게만 한다.
잘 들어갔다. 단순 select를 위해 더미데이터를 몇개 더 추가해준다.
- ApiContoller.class
@GetMapping("/users")
public List<User> findAll() {
List<User> users = new ArrayList<>();
try {
Iterable<User> it = userRepository.findAll();
for (User user : it) {
users.add(user);
}
return users;
}catch (Exception e) {
e.printStackTrace();
return users;
}
}
@GetMapping("/user/containing/{site}")
public List<User> findByEmailContaining(@PathVariable("site")String site) {
List<User> users = new ArrayList<>();
try {
Iterable<User> it = userRepository.findByEmailContaining(site);
for (User user : it) {
users.add(user);
}
return users;
}catch (Exception e) {
e.printStackTrace();
return users;
}
}
다해보면 좋겠지만 두가지만 추가해본다. 하나는 CrudRepository의 findAll과 내가 작성한 findByEmailContaining 을 테스트해본다.
findAll에는 내가추가해준 data가 전부나오고 /user/containing에서는 파라미터로 넘어오는 site가 포함된 email을 갖고 있는 user객체들을 성공적으로 불러오는 모습이다
사실 mybatis 이외에 다른 lib나 framework를 도입한적이없는데 Jpa를 써본 느낌은 굉장히 편할꺼같은 느낌이다 실제로 쿼리를 작성한적이 없는데 db에서 기본이되는 crud를 간단한 메서드명으로도 지원해주니 말이다. 근데 현업에서는 어떻게 사용할 지 조금 궁금한 생각도 든다 현업에서는 단순히 crud 이외의 복잡한 쿼리를 어떻게 관리하는지도 한번 알아봐야겠다
또 하나 더 느낀점은 Jpa는 java의 vo(jpa에서의 entity 또는 domain)와 db table간의 모델링이 굉장히 중요한듯하다. 역시 결국은 설계를 잘해야 프로그래밍을 잘하는듯 …
TDD는 Test-Driven-Development로써 직역하면 테스트 주도 개발입니다. 네…. 말그대로 테스트를 중심으로 개발한다는 개발방법론인데요. 간단하게 정의하면 기능이되는 코드 이전에 테스트코드를 작성해서 검증한 뒤 기능코드를 작성하는 방법입니다. 저같은 초급 개발자에겐 많이 생소한 느낌이지만 이미 많은 기업에서도 TDD를 적용하고 있습니다.
SW개발에 있어서 개발시간을 가장 크게 증가시키는 요인중에 하나가 바로 버그입니다. 이 버그는 Test가 정확하게 이루어지지 않아서 발생한다고 볼 수 있죠. TDD를 사용하면 선 테스트 후 개발이기 때문에 테스트를 거치지 않은 코드가 없습니다. 버그의 발생률을 상당히 줄여주는 이유기기도 하죠. 실제로 “TDD의 적용 여부에 따라서 개발시간이 30%가량 단축된다.” 라는 글도 있을 정도로 TDD는 SW개발에서 상당히 중요한 역할을 하고 있습니다.
TDD의 궁극적인 목표는 “Clean code that works” 인데요. 깔끔하고 잘 동작하는 코드입니다. TDD의 과정을 요약하면 다음과 같은데요
테스트를 작성한다.
작성한 테스트를 통과할 수 있도록 가장 빠른 방법으로 코드를 작성한다. 이 과정에 중복된 코드를 만들어도 상관 없다.
테스트를 수행한다.
테스트를 통과하면 작성한 코드에서 중복을 제거한다. 아니면 2번으로 돌아간다.
테스트를 수행한다.
테스트를 통과하면 완성. 다음 테스트를 1번부터 시작한다. 실패하면 4로 돌아가서 디버깅한다.
JUnit은 java 진영에서 가장 강력하고 유명하게 사용하는 테스트 프레임워크입니다. TDD에서 아주 중요한 역할을 해줍니다. 주요 메서드와 어노테이션을 알아보는 시간을 가져볼께요.
JUnit Assert Class
Assert class의 메서드들은 전부 static으로 선언되어 있기 때문에 Assert.assertEquals(“”, “”); 이런식으로 사용해도 되지만 편의성을 높이기 위해 보통은 class파일 최상단(package 바로 밑)에 import static org.junit.Assert.*; 이런식으로 정적선언 해준 뒤 메서드명만 사용하는게 일반적입니다.
Assert class 메서드의 true,false 값은 Test 메서드의 성공여부를 나타냅니다.
@Test를 붙임으로써 이 메서드가 테스트 케이스로 실행될 수 있음을 나타내줍니다. 반드시 public void methodName 형태여야 합니다.
@Before
@Before는 @Test 가 붙은 메서드가 실행되기 이전에 동작합니다. 보통 객체를 초기화시키는 작업에서 많이 사용하고 메서드 명은 setup을 사용합니다.
@After
@After는 @Before와 비슷하게 메서드가 실행되고 난 후에 동작합니다. 주로 변수설정을 다시하거나 임시파일을 삭제하는곳에서 사용합니다.
@Ignores
@Ignores는 말 그대로 테스트를 사용하지 않고 싶을때 사용합니다.
@Test(timeout=500)
@Test에 timeout 필드는 이 테스트에 시간제한을 명시해주는 필드입니다. 이 필드의 값은 ms 기준이고 테스트가 0.5초가 지나면 테스트 결과는 실패로 간주됩니다.
@Test(expected=IllegalArgumentException.class)
@Test에 expected 필드는 이 테스트가 던지는 예외를 지정할 수 있습니다. 만약 IllegalArgumentException.class가 테스트 도중 이 메서드로 던져지면 테스트 결과는 성공, 아니면 실패로 간주됩니다.
@RunWith(SpringRunner.class)
@Runwith는 JUnit 프레임워크의 확장입니다. 이를 이용하여 자신에게 필요한 테스트 러너를 직접 만들어서 자신만의 고유한 기능을 추가해 테스트를 수행할 수 있습니다. JUnit 5 이상 버전에서는 더이상 @Runwith가 아니라 @ExtendWith를 사용합니다.
@SpringBootTest
@SpringBootTest는 Spring legacy의 @ContextConfiguration 어노테이션을 SpringBoot에서 사용할 수 있도록 대체해주는 역할을 합니다. 이 어노테이션은 ApplicationContext를 만들고 테스트할 수 있도록 해줍니다. Spring boot 기준으로 반드시@RunWith(SpringRunner.class) 도 명시해주어야하며 이 또한 JUnit5 이상일 경우 @ExtendWith로 대체할 수 있습니다.
MockObject 란?
일단 Mock이란 직역하면 모조품이란 뜻을 가지고 있습니다. 객체지향 프로그래밍에서 Mock Object는 application을 테스트할 때 주로 사용되는 모의 객체입니다. spring-boot-starter-test 모듈에서도 역시 java mocking framework인 Mockito를 내장하고 있습니다.
A mock object can be useful in place of a real object that:
Runs slowly or inefficiently in practical situations
Occurs rarely and is difficult to produce artificially
Produces non-deterministic results
Does not yet exist in a practical sense
Is intended mainly or exclusively for conducting tests.
이 Mock을 사용하는 이유는 실제 A객체를 테스트해야하는데 A객체가 B객체와 의존되어있는 상황에서 B객체와 의존성을 단절시키기 위해 Mock으로 대체하여 테스트를 진행할 수 있습니다. Mock은 주로 Test하기 어려운 DB가 묶인 상황에도 많이 사용됩니다.
예제 시나리오는 회원가입 비지니스 로직에서 id,pw 를 가진 User.class를 db에 넣기 이전에 pw값을 암호화하여 db에 넣어주는 예제입니다. 물론 실 db는 안들어가요.
먼저 간단하게 풀어서 써보면
앞단에서 사용자 id, pw를 입력하고 회원가입 요청을 한다
서버는 id, pw가 유효한지 검증한다 (ex : id는 5자 이상, pw는 특수문자 포함 8자 이상 등..)
검증이 끝나면 pw를 암호화 알고리즘을 통해서 암호화 된 값으로 변환한다.
검증과 암호화가 끝난 id,pw를 db에 넣는다
정도가 될꺼 같습니다.
(3과 4는 join()에서 한번에 이루어지기 때문에 한번에 처리합니다.)
- User.class
package com.sup2is.demo;
public class User {
private String id;
private String pw;
public User(String id, String pw) {
this.id = id;
this.pw = pw;
}
//getter...
//setter...
}
먼저 필요한 VO객체 입니다. 그냥 id와 pw 필드만 있는 간단한 User객체입니다.
다음은 JoinService interface입니다.
- JoinService.interface
package com.sup2is.demo;
public interface JoinService {
public User join(User user);
public boolean verify(User user);
}
메서드는 join, verify를 갖고 있는 interface 골격이 되겠습니다.
다음은 JoinService를 구현하는 몸체가 되는 JoinServiceImpl.class 입니다
- JoinServiceImple.class
package com.sup2is.demo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class JoinServiceImpl implements JoinService {
//@Autowired 런타임 에러를 위해 cryptoService는 required false로 줌
@Autowired(required=false)
private CryptoService cryptoService;
@Override
public User join(User user) {
//user pw 암호화
user.setPw(cryptoService.encrypt(user.getPw()));
//join ... db에 넣는 로직 ..
return user;
}
@Override
public boolean verify(User user) {
//verify ...
return false;
}
}
verify 부분에서는 id,pw의 검증을 통해서 boolean값을 리턴하게 만들었습니다. 아직은 method 몸체랑 단순 return값 밖에 없는 상태예요.
join부분은 verify가 true일 경우에 동작하는데 주입받은 cryptoService를 이용해 cryptoService.encrypt(user.getPw()); 를 이용하여 암호화하는 부분입니다. 실 db에 넣지 않기 때문에 값을 확인하기 위하여 User객체를 반환합니다.
마지막으로
- CryptoService.interface
package com.sup2is.demo;
public interface CryptoService {
public String encrypt(String pw);
}
그냥 매우 간단하게 암호화에 필요한 encrypt() 메서드가 들어있습니다.
그리고 아직 CryptoService에 대한 스펙은 명시가 되어있지 않습니다. 아직 어떤 알고리즘을 사용하기로 얘기하지 않았기 때문이죠.
시나리오대로 Test Case를 작성해보겠습니다
1.앞단에서 사용자 id, pw를 입력하고 회원가입 요청을 한다
이제 Test 클래스를 작성해보겠습니다. 이에 앞서 Test클래스를 만들어놓고 여러개의 테스트를 작성해야하는데 메서드 네임이 도저히 생각나지 않을때는링크를 참고하시기 바랍니다!
MethodName_StateUnderTest_ExpectedBehavior
There are arguments against this strategy that if method names change as part of code refactoring than test name like this should also change or it becomes difficult to comprehend at a later stage. Following are some of the example:
isAdult_AgeLessThan18_False
withdrawMoney_InvalidAccount_ExceptionThrown
admitStudent_MissingMandatoryFields_FailToAdmit
저는 1번 방식으로 사용해보겠습니다. 후달리는 영어지만 되도록 영어를 사용하도록 노력해봅시다 …
@Autowired를 사용하기 때문에 Spring context가 필요하니 @Runwith와 @SpringBootTest를 명시해줍니다.
- JoinServiceTest.class
package com.sup2is.demo;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class JoinServiceTest {
@Autowired
private JoinService joinService;
@Test
public void testVerify_provideUser_verifyIsTrue() {
//given
User user = new User("sup2is", "12345678");
//when
boolean isValid = joinService.verify(user);
//then
assertEquals(isValid, true);
}
}
모든 Test Case는 given,when,then으로 역할을 명확하게 지정해서 하시면 좀 더 읽기 좋은 Test Case를 구성하실 수 있습니다.
given에는 User객체를 생성해주고 실제 user 객체를 사용할 then에서는 verify() 메서드를 호출해줬습니다. when에서의 결과 확인을하기 위해 assertEquals() 메서드를 호출해주었습니다.
assertEquals(isValid,true); 를 사용하여 값비교를 실행했습니다. 결과는 당연히 실패겠죠? 아직 저희는 verify()의 몸체를 작성하지 않고 return false로만 해뒀으니까요. 따라서 verify() 메서드를 수정해줍니다.
2.서버는 id, pw가 유효한지 검증한다 (ex : id는 5자 이상, pw는 특수문자 포함 8자 이상 등..)
- JoinServiceImpl.class
@Override
public boolean verify(User user) {
// id는 5자 이상 pw는 8자 이상
if(user.getId().length() >= 5 && user.getPw().length() >= 8) {
return true;
}
return false;
}
다시 테스트를 실행해봅니다. 초록불뜨는걸 확인했습니다. verify에서 특수문자도 검사해야하지만 id pw의 길이만 검사하기로 했습니다.
이제 join()를 테스트해보도록 하겠습니다.
3.검증이 끝나면 pw를 암호화 알고리즘을 통해서 해시값으로 변환한다.검증과 암호화가 끝난 id,pw를 db에 넣는다
4.검증과 암호화가 끝난 id,pw를 db에 넣는다
- JoinServiceTest.class
@Test
public void testVerify_provideUser_verifyIsTrue() {
//given
User user = new User("sup2is", "12345678");
//when
boolean isValid = joinService.verify(user);
//then
assertEquals(isValid, true);
}
@Test
public void testJoin_provideUser_joinSuccess() {
//given
User user = new User("sup2is", "12345678");
//when
User encryptUser = joinService.join(user);
//then
assertEquals("encrypted String", encryptUser.getPw());
}
테스트를 작성해보니 메서드 중복이 존재합니다. user를 생성해주는 부분을 JUnit에서 제공하는 @Before를 사용해서 중복제거를 해보도록하겠습니다.
- JoinServiceTest.class
//...
private User user;
@Before
public void setup() {
user = new User("sup2is", "12345678");
}
//...
@Test
public void testVerify_provideUser_verifyIsTrue() {
//given
//when
boolean isValid = joinService.verify(user);
//then
assertEquals(isValid, true);
}
@Test
public void testJoin_provideUser_joinSuccess() {
//given
//when
User encryptUser = joinService.join(user);
//then
assertEquals("encrypted String", encryptUser.getPw());
}
이제 testJoin_provideUser_joinSuccess() 를 실행해보겠습니다. 네 당연히 에러가 나겠죠? 저희는 아직 joinService에서 사용하는 cryptoService를 생성하지 않았으니까요. NullPointerException를 뿜뿜합니다.
근데 cryptoService의 상세 스펙이 아직도 정해지지않았다면 어떻게해야할까요? 아직 어떤 암호화 알고리즘을 사용할 지 결정되지 않았다면 테스트를 중지해야할까요?
이런상황에서 사용하는게 바로 mock입니다. mock객체를 사용해서 모조품을 만들고 join메서드를 완성시키도록 하겠습니다.
mockito에서 제공하는 어노테이션을 사용해서 joinservice 내부에 @Autowired로 받는 cryptoService를 mock객체로 주입해보겠습니다.
먼저 사용될 어노테이션에대해 간단하게 설명드립니다.
@Mock
@Mock은 Mock 객체를 생성해줍니다. mockito에서 제공하는 mock() 메서드와 동일한 역할을 합니다.
@InjectMocks
@InjectMocks 는 Mock객체가 필요한 객체에 Mock객체를 연결시켜주는 역할을 합니다.
*이런 … JoinService 타입이 interface이기 때문에 @InjectMocks으로 인스턴스화 할 수 없군요 .. ㅠㅠ 어쩔 수 없이 JoinServiceTest.class의 joinService 필드를 JoinServiceImpl 타입으로 변경하였습니다. 이 예제는 만들면서 진행한것이기 때문에 위에는 별도의 처리를 하지 않겠습니다.
joinService의 ctyptoService를 Autowired하기 위해 @Autowired로 주입받던 joinService를 @InjectMocks을 사용하여 Mockito에게 인스턴스화를 떠넘깁니다. 그와 동시에 @Mock을 사용하여 cryptoService를 mock객체로 생성해줍니다.
이제 마저 테스트를 실행해보겠습니다.
- JoinServiceTest.class
@InjectMocks // <-- @Autowired에서 @InjectMocks로 변경
JoinServiceImpl joinService; //<-- 구현체로 변경
@Mock // <-- mock 객체로 생성
CryptoService cryptoService;
// ...
@Test
public void testJoin_provideUser_joinSuccess() {
//given
when(cryptoService.encrypt(user.getPw())).thenReturn("encrypted String");
//when & then
User encryptUser = joinService.join(user);
assertEquals("encrypted String", encryptUser.getPw());
}
Test는 성공으로 떨어집니다. mockito에서 제공하는 when().thenReturn메서드를 통해 cryptoService.encrypt() 메서드의 몸체를 구현하지 않고 단순히 “encrypted String” 을 반환하게 만들었습니다.
이렇게 JoinService의 join() 메서드가 cryptoService의 encrypt() 메서드에게 의존하고 있음에도 불구하고 Mock을 사용하여 다른 CryptoService의 구현체 없이 테스트를 통과할 수 있었습니다. 앞서 말씀드린 의존관계를 어느정도 단절시키는 아주 좋은 예가 되었으면 좋겠습니다.
사실 저도 이번기회에 TDD 와 Mock을 제대로 접한거라 틀린 정보가 있을 수도 있고 예제 구현 방식에 있어서 미흡한 부분이 있을 수 있으니 참고바랍니다.
간단하게 용어설명을 하자면 Aspect-Oriented Programming인데요. 직역하면 관점지향 프로그래밍입니다. 네.. 해석한 한글이 더 어렵구요… 설명하기 쉽게 비슷한 개념으로 Java에서 사용중인 OOP가 있습니다. Object-Oriented Programming 이죠. 이건 많이 들어보셔서 아시겠지만 바로 객체지향 프로그래밍입니다. Java에서는 Object 즉 클래스 위주의 프로그래밍으로 하나의 프로그램을 구현하잖아요? AOP도 같은 개념입니다. 관점을 지향하는 프로그래밍 방식이예요.
AOP는 주로 공통관심사에 사용되는데 가장 대표적인게 logging, transaction, 인증 등의 처리가 되겠습니다. AOP에서는 cross-cutting이라는 개념이 있는데 이게 바로 logging, transaction, 인증이 아주 좋은 예가 될 수 있을꺼 같네요.
AOP를 사용하는 이유에 대해서 조금 더 설명하면 다음과같은 Foo.class는 여러개의 메서드를 갖고 있어요.
class Foo {
public void a(){
//something..
}
public void a2(){
//something..
}
public void a3(){
//something..
}
public void b(){
//something..
}
public void c(){
//something..
}
public void c2(){
//something..
}
public void d(){
//something..
}
}
만약 a로 시작하는 메서드의 시작과 끝부분에 log를 남겨 메서드의 실행시간을 체크하고 싶은 경우가 있을 수 있죠. 그렇다면 이때 a로 시작하는 메서드 시작과 끝부분에 로직을 추가해 메서드의 실행시간을 체크할 수 있습니다. 하지만 별로 좋은생각은 아니죠 만약 Foo.class의 a메서드 뿐만 아니라 수많은 클래스의 a메서드, 또는 프로그램에서 호출하는 모든 메서드에 실행시간을 체크하고 싶다면? 저 방법으로는 어느정도 한계가 있죠. 그래서 사용하는게 AOP입니다.
AOP 용어
Advice
Advice는 제공할 서비스를 나타내는데요. aspect가 무엇을 언제 할지 정의하는 역할을 합니다.
Spring에서는 총 5개의 관점이 있습니다.
Before : 메소드 실행 전 Advice 실행
After : 메소드 실행 후 Advice 실행
After-returning : 메서드가 성공 후(예외 없이) Advice 실행
After-throwing : 메서드가 예외발생 후 Advice 실행
Around : 메소드 실행 전과 후 Advice 실행 (Before + After)
Joinpoint
JoinPoint는 AOP를 적용할 수 있는 지점을 나타냅니다. Spring AOP에서 join point는 항상 메소드 실행을 나타냅니다.
Pointcut
Pointcut은 표현식이나 패턴들을 활용하는 AOP의 EL이라고 생각하시면 되고 하나 또는 여러개의 joinpoint 집합입니다.
- com.sup2is.service의 서브패키지를 포함한 패키지 안에 모든 메서드에 실행
within(com.sup2is.service.*)
- com.sup2is.service 패키지 안에 모든 joinpoint에 실행
within(com.sup2is.service..*)
- com.sup2is.service의 서브패키지를 포함한 패키지 안에 모든 joinpoint에 실행
이 외에도 this, target,arg,@target,@within 등등이 있습니다.
Introduction
Introduction을 통해 기존 클래스에 새 메소드나 특성을 추가 할 수 있습니다.
Aspect
여러 객체에 공통 관심사 cross-cutting 개념을 갖고 있습니다. 위에서 설명한 logging,transaction,인증이 아주 좋은 예입니다.
Weaving
Weaving은 advice를 다른 application 또는 Object와 관점을 연결하여 개체를 만드는 프로세스입니다. Weaving은 Compile tile, Run time, Load time에 실행될 수 있습니다. Spring AOP 에서는 Runtime에 동작합니다.
Target Object
Target Object는 말 그대로 Advice가 적용된 하나 또는 여러개의 관점들 입니다. Spring AOP는 Runtime Proxy를 사용하여 구현되기 때문에 Spring proxy 객체로 알려져 있습니다.
용어 설명은 이정도로 마치고.. 예제를 통해서 직접 사용해보는게 좋을것 같아요
Example (Spring boot 2.1.3)
먼저 간단한 시나리오는 덧셈,뺄셈,나눗셈,곱셈의 동작을 하는 Calculator 객체가 있는데 이 메서드들에게 적절한 pointcut EL을 사용하여 log를 찍어보는 예제입니다. 매우 간단해요.
- MyCalculator.class
package com.sup2is.demo;
import org.springframework.stereotype.Component;
@Component
public class MyCalculator {
public int add(int a, int b) {
System.out.println("### add method 실행");
return a + b;
}
public int sub(int a, int b) {
System.out.println("### sub method 실행");
return a - b;
}
public int division(int a, int b) {
System.out.println("### division method 실행");
return a / b;
}
public int multiply(int a, int b) {
System.out.println("### multiply method 실행");
return a * b;
}
}
네 .. 정말 간단하네요 ..
이제 cross-cutting을 적용해볼께요.
@Before
- CalculationAspect.class
package com.sup2is.demo;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
@Aspect //Aspect 명시
@Component //반드시 Component 등의 Spring Bean으로 등록해야 Aspect가 제대로 적용됨
public class CalculationAspect {
// com.sup2is.demo.MyCalculator class 내부의 모든 메서드에 실행
@Before("execution(* com.sup2is.demo.MyCalculator.*(..))")
public void beforeLog(JoinPoint joinPoint) {
System.out.println("### " + joinPoint.getSignature().getName() +
" : before execute");
}
}
이 CalculationAspect.class 에서 주목해야 하는부분은 바로 beforeLog() 메서드의 파라미터인 org.aspectj.lang.Joinpoint interface인데요. @Before, @After, @AfterReturning, @AfterThrowing 에 선택적으로 파라미터를 명시하면 자동으로 메서드에 파라미터가 넘어오게됩니다. 이 joinpoint는 getSignature() 메서드 처럼 AOP가 호출된 메서드의 정보 등이 넘어오니 자세한 내용은링크를 직접확인해보시는게 좋을 것 같아요.
- SpringDemoApplicationTests.class
package com.sup2is.demo;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringDemoApplicationTests {
@Autowired
private MyCalculator myCalculator;
@Test
public void addTest() {
System.out.println(myCalculator.add(5, 5));
}
@Test
public void subTest() {
System.out.println(myCalculator.sub(5, 5));
}
@Test
public void divisionTest() {
System.out.println(myCalculator.division(5, 5));
}
@Test
public void multiplyTest() {
System.out.println(myCalculator.multiply(5, 5));
}
}
저는 @Before 어노테이션을 사용해서 MyCalculator.class 내부에 동작하는 모든 메서드에
### 메서드 이름 : before execute 라는 로그를 찍게 구현해봤어요. 실행해보겠습니다.
- console
...
### sub : before execute
### sub method 실행
0
### division : before execute
### division method 실행
1
### add : before execute
### add method 실행
10
### multiply : beforeexecutee
### multiply method 실행
25
생각보다 간단하죠? 저는 분명 MyCalculator.class 내부에 log를 넣어놓은 적이 없는데 AOP가 알아서 log를 남겨주고 있습니다.
@After
마찬가지로 @After는 메서드 실행 이후에 동작합니다.
- CalculationAspect.class
// com.sup2is.demo.MyCalculator class 내부의 add 메서드에 실행
@After("execution(* com.sup2is.demo.MyCalculator.add(..))")
public void afterLog(JoinPoint joinPoint) {
System.out.println("### " + joinPoint.getSignature().getName() +
" : after execute");
}
@After는 add라는 메서드에만 동작하게 해놨는데요. 실행해보면
- console
### sub : before execute
### sub method 실행
0
### division : before execute
### division method 실행
1
### add : before execute
### add method 실행
### add : after execute
10
### multiply : before execute
### multiply method 실행
25
보시는것처럼 add 메서드에만 @After 가 동작한걸 확인하실 수 있어요.
@AfterReturing
@AfterReturing은 메서드가 예외 없이 성공적으로 끝났을때 호출이 되는데요.
이번에는 add 메서드와 division 메서드에 동작시키도록 해 보겠습니다. 메서드명에 공통적으로시작하는 접두어가 있다면 com.sup2is.demo.MyCalculator.find*(..) 으로 표현식을 작성 할 수 있지만 그렇지 않다면 && 와 를 이용해 표현식을 연결 할 수 있습니다.
- CalculationAspect.class
// com.sup2is.demo.MyCalculator class 내부의 add,division 메서드에 실행
@AfterReturning("execution(* com.sup2is.demo.MyCalculator.add(..) || execution(* com.sup2is.demo.MyCalculator.division(..)" )
public void afterReturning(JoinPoint joinPoint) {
System.out.println("### " + joinPoint.getSignature().getName() +" : after returning execute");
}
@Test
public void addTest() {
System.out.println(myCalculator.add(5, 5));
}
...
@Test
public void divisionTest() {
System.out.println(myCalculator.division(5, 0));
}
- console
### sub : before execute
### sub method 실행
0
### division : before execute
### division method 실행 <-- java.lang.ArithmeticException 발생
### add : before execute
### add method 실행
### add : after execute
### add : after returning execute
10
### multiply : before execute
### multiply method 실행
25
성공적으로 값을 반환한 add메서드와는 달리 division은 @AfterRiturning이 적용되지 않은걸 확인하실 수 있습니다.
추가적으로 @AfterReturning은 반환한 return값을 가져올 수 있는 returning 필드가 @AfterReturning 어노테이션 필드에 내장되어 있는데요. 말그대로 Aspect가 적용된 메서드의 return값을 메서드 내부에서 사용할 수 있습니다. 아주 유용하게 쓰일 수 있죠.
- CalculationAspect.class
// com.sup2is.demo.MyCalculator class 내부의 add,division 메서드에 실행
@AfterReturning(pointcut = "execution(* com.sup2is.demo.MyCalculator.add(..)) ||"
+ " execution(* com.sup2is.demo.MyCalculator.division(..))" ,
returning="value")
public void afterReturning(JoinPoint joinPoint, Integer value) {
System.out.println("### " + joinPoint.getSignature().getName() +
" : after returning execute");
System.out.println("### value : " + value );
}
- console
### add : before execute
### add method 실행
### add : after execute
### add : after returning execute
### value : 10
10
@AfterThrowing 어노테이션은 throwing이라는 필드를 갖고 있는데요. 예외가 발생했을때 Aspect 내부에 Exception 객체를 전달해주는 역할을 합니다. 파라미터에 Exception.class를 입력하면 Exception전부를 잡아내지만 타입을 강하게 ArithmeticException.class로 준다면 ArithmeticException이 발생된 메서드만 @AfterThrowing이 발생합니다.
- MyCalculator.class
public int multiply(int a, int b) {
System.out.println("### multiply method 실행");
if(a == 0) {
throw new IllegalArgumentException();
}
return a * b;
}
간단하게 multiply 메서드를 조금 수정해줬는데요. 첫번째 파라미터가 0이면 IllegalArgumentException(); 를 반환하게 수정했습니다.
- SpringDemoApplicationTests.class
@Test
public void divisionTest() {
System.out.println(myCalculator.division(5, 0));
}
@Test
public void multiplyTest() {
System.out.println(myCalculator.multiply(0, 5));
}
- console
### sub : before execute
### sub method 실행
0
### division : before execute
### division method 실행
### division : afterThrowing execute
### java.lang.ArithmeticException: / by zero : exception occurred
### add : before execute
### add method 실행
### add : after execute
### add : after returning execute
10
### multiply : before execute
### multiply method 실행
보시는것처럼 division 메서드는 ArithmeticException이 발생했기 때문에 @AfterThrowing이 적절하게 발생했지만 multiply 메서드는 그렇지 않은걸 확인하실 수 있습니다.
@Around
@Around는 AOP 중에서도 가장 강력하게 작용하는 Advice입니다. 이 @Around는 다른 어노테이션과는 달리 메서드의 첫번째 인자로 반드시 JoinPoint의 하위타입인 org.aspectj.lang.ProceedingJoinPoint.class 가 반드시 와야 합니다. 메서드의 흐름을 보면 ProceedingJoinPoint.proceed 메서드를 통해 Advice가 적용된 메서드의 실행여부를 @Around내부에서 직접 제어할 수 있습니다.
CalculationAspect.class
@Around("execution(* com.sup2is.demo.MyCalculator.sub(..))")
public Object aroundLog(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("### " + joinPoint.getSignature().getName() +
" : before around excute");
try {
Object result = joinPoint.proceed();
return result;
}finally {
System.out.println("### " + joinPoint.getSignature().getName() +
" : after around excute");
}
}
메서드의 실행순서는 @Advice가 먼저 실행되고 그 이후에 proceed 메서드가 실행되는데 proceed 메서드의 리턴값이 바로 sub메서드의 return값이 됩니다. 이 말은 @Advice 내부에서 결과값을 제어 할 수도 있다는거죠.
- SpringDemoApplicationTests.class
@Test
public void subTest() {
System.out.println(myCalculator.sub(5, 5));
}
- console
### sub : before around excute
### sub : before execute
### sub method 실행
### sub : after around excute
0
이전에 적용했던 @Before와 @Advice에 순서도 확인하실 수 있으시죠?
@Around를 조금 변형해서 만약 결과값이 0이면 -1을 return하도록 수정해보겠습니다.
- CalculationAspect.class
// com.sup2is.demo.MyCalculator class 내부의 sub 메서드에 실행
@Around("execution(* com.sup2is.demo.MyCalculator.sub(..))")
public Object aroundLog(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("### " + joinPoint.getSignature().getName() +
" : before around excute");
try {
Object result = joinPoint.proceed();
if(Integer.parseInt(result.toString()) == 0) {
return -1;
}
return result;
}finally {
System.out.println("### " + joinPoint.getSignature().getName() + "
: after around excute");
}
}
- MyCalculator.class
public int sub(int a, int b) {
System.out.println("### sub method 실행");
return a - b;
}
- console
### sub : before around excute
### sub : before execute
### sub method 실행
### sub : after around excute
-1
확인하시는것처럼 메서드 내부에는 전혀 수정이 없었지만 @Around 내부에서 값을 제어하는것을 확인하실 수 있습니다.
스프링은 Rod Johnson이 2002년도에 발표한 ‘Expert One-on-One J2EE Design and Development’ 라는 책에서 발표한 Java 진영의 Framework입니다. 현재 한국 web진영에서 가장 많이 사용한다고 해도 과언이 아닐 정도인데요. Spring은 POJO(plain old Java objects) 기반으로 application을 작성할 수 있도록 도와줍니다. Spring doc 에는 다음과 같은 문장이 있습니다.
Make a Java method execute in a database transaction without having to deal with transaction APIs.
Make a local Java method a remote procedure without having to deal with remote APIs.
Make a local Java method a management operation without having to deal with JMX APIs.
Make a local Java method a message handler without having to deal with JMS APIs.
뭐 .. 대충 transaction APIs , remote API … 등등 을 사용하지 않고 java application을 작성할 수 있도록 도와준다는 것 같습니다. 그리고 이게 바로 위에서 언급한 POJO이기도 하구요.
저는 스프링을 배울때 3.x 대 부터 사용했었는데 현재 19.03.18 기준으로 mvn repository에 5.1.5 버전이 release 되었네요. 당시에 저도 처음 배울때는 Spring 하면 Web을 가장 먼저 떠올렸었는데 그 외에도 Spring Batch, Spring Cloud 등등 … 아주 많은 곳에서 Spring을 사용하고 있습니다.
Spring 하면 대표적으로 떠오르는게 바로 Dependency Injection / Inversion of Control 인데요. 이제 본격적으로 DI/IoC , Spring Architecture 를 한번 파헤치는 시간을 가졌으면 좋겠어요! (저도 공부 할 겸 …)
Spring Architecture
Spring 공부하시는 분들은 많이 보셨을만한 그림인데요. Data Access 부터 Web, Core Container 등 다양하게 많습니다. 차근히 알아볼께요!
Dependency Injection / Inversion of Control
제가 생각하는 Spring core의 핵심! 바로 DI/IoC 인데요 사실 스프링 doc을 읽어봐도 DI/IoC 개념이 확 와닿지 않았어요. (저는..) DI/IoC를 그대로 직역하면 DI는 ‘의존성 주입‘, IoC는 ‘제어의 역행’ 인데요. DI는 무언가에 의존되어 주입이 되는거고 IoC는 말그대로 제어의 역행을 뜻합니다.
제어의 역행을 설명드리면 보통 Java에서 객체를 생성할때 new라는 예약어를 사용해서 객체를 생성하게 되잖아요? 근데 보통 이 new라는 예약어는 개발자가 소스코드에서 직접 선언을 하게 됩니다. 즉 객체를 관리하는 주체가 개발자가 되어요. 만약 개발자가 실수 또는 잘못된 습관으로 A 라는 객체를 두번 생성했다거나 굉장히 큰 프로젝트에서 A라는 객체를 수정해야 할 때, 어디서 A라는 객체를 생성했는지 전부 디버깅해서 하나하나 씩 바꿔주어야 하는 상황이 발생하죠.
이런 문제를 개선할 수 있는게 바로 IoC 인데요. Spring에서는 더이상 new 연산자로 객체를 생성하지 않습니다. 바로 이 객체의 생성을 Spring에게 전가시키는건데요. 이것도 생각보다 간단해요. Spring Project가 컴파일러에 의해 Runtime시점으로 들어가기 이전에 Spring은 IoC Container를 통해서 Spring에 사용할 객체들을 초기화시키는 작업을 합니다. 즉 A라는 객체를 Spring Bean에 등록하고 A라는 객체를 사용할때는 IoC Container에서 A라는 객체를 주입시켜서 사용하는건데요. 여기서 말하는 주입이 바로 DI의 개념입니다. (Spring에서 Bean이라는 용어는 객체를 설명하고 있어요)
Spring DI/IoC 관련해서 옛날에 봤던 동영상인데 저는 아주 좋게 본 기억이 있어서 같이 공유하고 싶어요
“마찬가지로 개발자는 더이상 new 연산자를 사용하지 않고 객체의 생성을 Spring에게 전가시킨다. 이게 IoC, 그리고 Spring Container에서 객체들을 주입받아 사용하는게 바로 DI”
Spring IoC Container를 초기화 시키고 DI를 구현하는 방법은 크게 xml방식, Java Config 방식이 있는데 과거에는 xml방식을 많이 사용했지만 최근에는 Spring Boot에서 Java Config 방식을 지향하고 있기 때문에 유지보수와 신규개발을 동시에 하려면 둘 다 알아야 겠지요..
package com.sup2is.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ImportResource;
//@SpringBootApplication 은 @ComponentScan 을 포함하기 때문에 package 기반으로 Bean을 Scan해줍니다.
@SpringBootApplication
//Spring이 Bean을 초기화할때 xml이 어디있는지 모르니 반드시 명시해줘야 xml방식으로 Bean이 올라가요
@ImportResource({"classpath:spring-context.xml"})
public class SpringDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringDemoApplication.class, args);
}
}
Java Config 방식
Spring Bean으로 등록하는 어노테이션은 여러개가 있지만 그중 몇가지만 살펴 볼께요.
- FooComponentA.class
package com.sup2is.demo;
import org.springframework.stereotype.Component;
@Component //Spring이 Bean을 초기화 할 때 @Component 가 붙은 클래스를 확인하고 해당 클래스를 Bean으로 등록
public class FooComponentA {
public void print() {
System.out.println(FooComponentA.class.getSimpleName() + " : hello world");
}
}
- AppConfigration.class
package com.sup2is.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration //마찬가지로 Spring이 Bean을 초기화 할 때 @Configuration 붙은 클래스를 확인
public class AppConfigration {
@Bean //@Configuration 밑에 @Bean 어노테이션도 Spring Bean으로 등록
public FooComponentB componentB() {
return new FooComponentB();
}
}
- FooComponentB.class
package com.sup2is.demo;
public class FooComponentB {
public void print() {
System.out.println(FooComponentB.class.getSimpleName() + " : hello world");
}
}
package com.sup2is.demo;
public class FooComponentC {
private String name;
//setter 메서드로 초기화
public void setName(String name) {
this.name = name;
}
public void print() {
System.out.println(FooComponentC.class.getSimpleName() + " : hello world");
}
public void printName() {
System.out.println("my name is " + name);
}
}
테스트
그런데 지금 등록한 이 Spring Bean들은 눈에보이지 않는 메모리에 올라가 있으니 확인할 방법이 없잖아요 그럴때는 DefaultListableBeanFactory.class를 주입받아서 사용해 볼께요. 근데 저희는 DefaultListableBeanFactory.class를 등록한 적이 없는데요? 라고 생각하실 수 도 있는데 Spring에서 기본적으로 초기화되는 Bean들은 여러개가 있어요
- SpringDemoApplicationTests.class
package com.sup2is.demo;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringDemoApplicationTests {
//Spring Bean에 등록된것들중 동일한 타입이 있으면 자동으로 주입해주는 어노테이션
@Autowired
private DefaultListableBeanFactory beanFactory;
@Test
public void beanCheckTest() {
for(String name : beanFactory.getBeanDefinitionNames()) {
System.out.println("Bean name : " + beanFactory.getBean(name).getClass().getName());
}
}
}
- console
Bean name : org.springframework.context.event.EventListenerMethodProcessor
Bean name : org.springframework.context.event.DefaultEventListenerFactory
Bean name : com.sup2is.demo.SpringDemoApplication$$EnhancerBySpringCGLIB$$ab4a679f
Bean name : org.springframework.boot.type.classreading.ConcurrentReferenceCachingMetadataReaderFactory
Bean name : com.sup2is.demo.AppConfigration$$EnhancerBySpringCGLIB$$a0500705
Bean name : com.sup2is.demo.FooComponentA
Bean name : com.sup2is.demo.FooComponentB
Bean name : com.sup2is.demo.FooComponentC
...
저희가 xml과 JavaConfig로 등록한 FooComponent들이 Spring Bean에 등록된걸 확인하실 수 있습니다.
아까 xml로 name값을 초기화 했던 FooComponentC의 printName 메서드도 한번 확인해볼께요
2019-03-21 11:27:18.925 INFO 16220 --- [ main] c.s.demo.SpringDemoApplicationTests : Started SpringDemoApplicationTests in 2.36 seconds (JVM running for 3.5)
my name is sup2is demo project
2019-03-21 11:27:19.181 INFO 16220 --- [ Thread-2] o.s.s.concurrent.ThreadPoolTaskExecutor : Shutting down ExecutorService 'applicationTaskExecutor'
...
“my name is sup2is demo project” 가 콘솔에 잘 찍히는걸 확인하실 수 있죠?
이렇게 Spring Container에 올라간 Bean들은 따로 scope를 정해주지 않는 이상 singleton으로 메모리에 올라가게 되는데 sington은 나중에 알아볼께요 .. 허허