개발을 하다보면 기존에 다른 사람들이 작업한 프로젝트를 이어받아서 작업해야 하는 경우도 생긴다. 

이때 라이브러리 버전 관리, 환경변수 설정 등이 알맞게 되어야만 이전 사람들이 프로젝트를 진행하던 똑같은 환경에서 개발을 진행할 수 있다.

오늘은 간단하게 그 순서에 대해서 알아보겠다. 

 

📒가상 환경

가상환경 만들기

우선 가상환경을 하나 만들어준다. 

 

가상환경을 만들기 위해서는 virtualenv라는 라이브러리가 필요하다. 없다면 아래 커맨드를 이용해서 virtualenv 라이브러리를 설치하자. 

이때 커맨드를 실행하는 위치는 프로젝트 root 디렉토리로 되어 있어야 한다. 

pip install virtualenv

 

 

이제 가상환경을 만들어준다. 지금 만든 가상환경 안에서 프로젝트에 쓰일 라이브러리와 버전을 관리할 것이다. 

virtualenv 뒤에는 가상환경의 이름을 붙여주면 된다. 보통은 venv를 많이 사용한다. 

virtualenv venv		// 가상환경 이름: venv
virtualenv myenv	// 가상환경 이름: myenv

 

때때로 python 버전에 따라서 사용 가능한 라이브러리가 달라지기도 한다. 

이 경우엔 가상환경을 만들 때 어떤 버전을 사용해서 가상환경을 운영할지도 따로 정할 수 있다. 

virtualenv venv --python=3.8	// python 3.8을 사용하는 가상환경 설치

 

가상환경을 만드는 이유

가상환경(virtual environment)을 사용하는 이유는 여러 가지가 있을 수 있다. 대표적인 이유는 여러 개의 프로젝트를 동시에 진행할 때 라이브러리 버전 관리가 쉽기 때문이다. 

 

예를 들어 프로젝트 A는 django-rest-framework 0.0.1 버전을 사용하고 프로젝트 B는 0.0.2 버전을 사용할 경우, 버전이 다르기 때문에 프로젝트를 바꿔 가면서 작업할 경우 버전에 혼동이 생긴다. 심한 경우는 버전이 맞지 않아서 실행되지 않을 수 있다. 

 

그러나 프로젝트별로 개별의 가상 환경을 만들고 그 안에서 작업하면, 가상환경마다 사용하는 라이브러리와 버전이 다르고, 프로젝트별로 어떤 버전을 사용하건 다른 프로젝트에 영향을 주지 않는다. 

 

가상환경 실행하기

위에서는 가상환경을 만들었다. 앞으로 프로젝트를 작업할 때마다 이 가상환경을 실행한 상태에서 작업해야 한다. 

(가상환경에 프로젝트에 필요한 라이브러리가 모두 포함되기 때문에, 가상환경을 실행하지 않으면 어차피 프로젝트를 실행할 수 없다.)

 

윈도우에서 가상환경을 실행하는 방법은 Command Prompt에서 실행하는 방법과 PowerShell에서 실행하는 방법 두 가지가 있다. 

 

Command Prompt(cmd 창)에서 실행하는 방법은, 가상환경 이름\Scripts\activate.bat

venv\Scripts\activate.bat

 

PowerShell에서 실행하는 방법은, .\가상환경 이름\Scripts\Activate.ps1 

.\venv\Scripts\Activate.ps1

나는 항상 cmd로 작업해서 위의 방법을 사용했다. 그러나 powershell이 mac과 linux에서 사용하는 커맨드를 사용한다고 들어서, 앞으로는 powershell 명령어도 많이 익힐 필요가 있겠다. 

아무튼 이 과정을 마치면 터미널 창에서 뜨던 디렉토리 앞에 (venv) 가 붙는다. 이는 가상환경이 현재 실행 중이라는 뜻이다. 이제 작업하면 된다!

 

라이브러리 설치

본인이 처음부터 끝까지 프로젝트를 만드는 경우라면 가상환경 작업을 끝내고 바로 프로젝트를 만들어도 되지만, 다른 사람들과 같이 개발하는 경우는 추가 작업이 필요하다. 

 

현재 프로젝트 가상환경에서 사용한 라이브러리를 파일로 저장하기

여러 명이 프로젝트 하나를 개발하는 경우, 사람들이 작업하던 환경과 같은 환경에서 프로젝트를 개발해야 한다. 이때 라이브러리의 버전 관리 등을 쉽게 하기 위해서는 사용한 라이브러리의 버전을 모아서 관리할 필요가 있다. 

 

이럴 때 pip의 freeze 기능을 사용한다. 

pip freeze > requirements.txt

freeze를 하면 현재 프로젝트에서 사용된 라이브러리와 버전을 requirements.txt 파일을 만들어서 그 안에다 저장해 준다. 

이러면 나중에 다른 사람이 이 프로젝트를 작업하게 되었을 때, requirements.txt 파일을 사용하여 똑같은 라이브러리와 버전을 가상환경에 설치하여 사용할 수 있다. 

 

프로젝트에서 사용한 라이브러리를 설치하기

이 경우는 위의 경우와는 반대로, 다른 사람이 작업했던 프로젝트를 이어받아 작업할 때 사용한다. 

앞서 작성된 requirements.txt 파일에 설치된 라이브러리와 버전을 하나하나 다운받지 않고, 다음 명령어를 사용한다. 

(파일명을 다르게 하고 싶으면 뒤의 부분을 바꾸면 된다.)

pip install -r requirements.txt

requirements.txt 파일에 입력된 라이브러리와 버전을 현재 가상환경에 설치해 준다. 

 

⚠️설정 파일(requirements.txt)은 딱 하나만 두고 관리하자. 

여러 사람이 작업하다 보면 여러 개의 설정 파일이 생길 수 있다. 그러면 때론 파일을 혼동하여, 가장 최근의 라이브러리 설치 상태를 반영한 파일이 아닌 다른 파일에 작성된 라이브러리를 설치하게 될 수 있다. (내가 했던 실수...)

따라서 설정 파일(requirements.txt)은 꼭 하나만 두고 관리하자. 

 

위의 두 과정에서 별다른 오류가 없었다면, 프로젝트 실행을 위한 기본적인 작업은 끝났다. 이제는 환경변수 작업만 남았다. 

 

📒환경변수(.env)

프로젝트를 실행하기 위해서 필요한 중요한 정보를 담고 있는 변수. 

보통 데이터베이스의 포트 번호, 비밀번호 등을 포함하기 때문에 절대 외부에 공유해서는 안 된다. (비밀번호 다 털림. 계정 털림.)

 

환경변수 파일 이름은 보통 .env 로 사용한다. 

 

gitignore 사용해서 환경변수 파일이 github/git에 공유되지 않게 하기

이 파일을 공유하지 않으려면 gitignore를 활용해야 한다. 

.gitignore 파일을 만들고, 이 안에다가 깃허브에 공유하고 싶지 않은 파일 이름 또는 확장자를 적어주면 된다. 

 

예를 들어서, .gitignore 파일에 다음과 같이 입력해 보자.

.env
.venv
info.py

그리고 프로젝트를 깃허브에 등록할 때, 확장자가 env 또는 venv인 파일과 info.py 파일은 깃허브에 등록되지 않는다. 

 

⚠️예외 경우

다만 예외인 경우도 있다. 이미 git에 등록되어 관리되던 파일이라면, 관리된 이후에 .gitignore 파일에 추가된다고 해서 업로드에서 제외되지 않는다. 

 

이 경우 추가적인 git 명령어를 사용해서 작업해야 한다. 

 

작업 과정

1. .gitignore 파일에 깃허브에 올리지 않을 파일명 추가

2. git 캐시 삭제

--cached 옵션을 추가하지 않으면 로컬 저장소에 있는 파일과 원격 저장소(깃허브)에 있는 파일이 모두 삭제된다. 

지금은 원격 저장소인 깃허브에 등록된 파일만 삭제하려는 것이므로 꼭 --cached 옵션을 붙여주자. 

git rm -r --cached .

3. git에 현재 작업한 내용을 임시로 저장

: add 뒤에는 현재 디렉토리를 명시하기 위해서 . 하나를 붙인다. 

git add .

4. 현재 git 프로젝트의 상태 확인

: 현재 등록된 커밋(commit)이 있는지를 보여준다. 

git status

5. 변경사항 저장하기

git commit

 

.env 파일 간단하게 생성하는 방법

gitignore.io - 자신의 프로젝트에 꼭 맞는 .gitignore 파일을 만드세요 (toptal.com)

이 링크에서 본인의 운영체제와 사용하는 언어/프레임워크를 검색어로 입력하면 그에 맞는 .env 파일 형식을 생성해 준다. 

이를 참고하여 제외할 부분은 제외하고, 추가할 부분은 추가로 작성하는 방식으로 .env 파일을 작성할 수 있다. 

 

📒Git(깃)

Git은 컴퓨터 파일의 변경사항을 추적하고 여러 명의 사용자들 간에 해당 파일들의 작업을 조율하기 위한 분산 버전 관리 시스템다.

 

로컬 저장소와 원격 저장소

여러 명의 사용자는 각자의 로컬 PC에서 작업을 진행하고, 이를 원격 저장소에 공유하는 방식으로 작업한다. 

 각자의 PC=로컬 저장소, Github 계정=원격 저장소 이다. 

 

파일 업로드 과정

개인 PC에서 작업한 이후에는 작업내용을 공동 저장소인 깃허브에도 저장해야 한다. 

이 과정을 보통 '커밋을 한다'고 하지만, 구체적으로는 네 가지 상태로 나눠진다. 

 

0) Working Directory

작업하면서 생긴 변경사항들이 아무 영역에도 반영되지 않은 상태이다. 

 

1) Staging

작업하면서 생긴 변경사항이 '임시저장' 된 단계이다. Github Desktop 등의 프로그램을 사용하면 변경사항이 생기자마자 바로 staging 영역으로 등록된다. 

그렇지 않은 경우, git add . 명령어를 사용해서 현재 디렉토리에서 발생한 변경사항을 임시저장인 staging 영역으로 등록해 준다. 

 

2) Commit

Staging 영역으로 등록된 변경사항들이 기록으로 남는 영역이다. 아직 깃허브에 변경사항이 저장된 상태는 아니다. git commit 명령어를 통해 실행할 수 있다. 

다만 Staging 단계와의 차이점은 Commit부터는 깃허브에서 작업 기록으로 남는다는 것이다. Staging 단계에서는 add를 남발해도 임시저장의 개념이라 아무런 기록이 남지 않지만, commit을 남발하면 그 변경사항이 모두 기록된다. 

따라서 보통의 공동작업에서는 커밋을 남발하지 않고, 기능 추가/기능 개선/오류 처리 등 여러 기준을 세우고 그에 따라서 커밋을 하는 것이 일반적이다. 

 

3) Push

commit 된 사항에 대해서 push를 하도록 권장한다. push 된 내용은 깃허브에 반영될 수 있다. 

git push 명령어를 통해 실행할 수 있다.

다만 프로젝트에 따라서 개인이 push하면 바로 깃허브에 반영이 되도록 할 수도 있고, 여러 사용자가 확인 뒤 승인(?)하면 변경사항이 반영되도록 할 수도 있다. 

 

 

참고한 포스트

[Git] gitignore란 무엇일까? :: Gyun's 개발일지 (tistory.com)

git rm --cached 파일 삭제 :: 마이구미 :: 마이구미의 HelloWorld (tistory.com)

[GIT] area, add, commit, push, stage (velog.io)

 

 

이 포스트는 인프런 김영한 님의 '스프링 핵심 원리 - 기본편' 강의를 들으면서 내용을 정리한 글입니다. 

 

[메인 컨텐츠]

스프링 핵심 원리 - 기본편 대시보드 - 인프런 | 강의 (inflearn.com)

 

📅 2022-05-23 

ℹ️ 목차

1. 컴포넌트 스캔과 의존관계 자동 주입 시작하기

2. 탐색 위치와 기본 스캔 대상

3. 필터

4. 중복 등록과 충돌

 


1. 컴포넌트 스캔과 의존관계 자동 주입 시작하기

컴포넌트 스캔의 필요성

지금까지는 스프링 빈을 수동으로 하나씩 등록하는 방법을 알아보았다. 
그러나 등록할 빈의 개수가 많아질수록 단순 반복 작업이 될 수 있고, 누락할 수 있다는 문제점도 생긴다. 

 

스프링에서는 자동으로 빈을 등록해주는 컴포넌트 스캔이라는 기능이 있다. 

 

컴포넌트 스캔(Component Scan)스프링 빈을 자동으로 끌어오는 기술.

 

코드로 보자.

AppConfig.java : 수동으로 스프링 빈을 등록한 클래스 파일

AutoAppConfig.java : 자동으로 빈을 등록할 클래스 파일

 

컴포넌트 스캔의 원리

@ComponentScan을 사용해서 컴포넌트 스캔을 할 수 있다. 

@ComponentScan은 @Component이 붙은 클래스를 찾아서 전부 스프링 빈으로 등록한다. 

@Configuration // 싱글톤 패턴으로 객체 생성
@ComponentScan	// 컴포넌트 스캔 역할
public class AutoAppConfig {
	// 별도의 코드 필요 X
}

 

@ComponentScan@Component 어노테이션만 스캔할까?

@Component 어노테이션은 다른 어노테이션 안에 붙어있기도 한다. 

ex) 

@Configuration 어노테이션을 타고 들어가면(원본 코드를 보면), @Component 어노테이션이 포함되어 있다. 이 경우 @Configuration이 붙은 클래스도 스프링 빈으로 등록된다. 
물론 @Configuration 말고도 @Component을 포함하는 어노테이션들은 많고, 스프링 컨테이너는 이 어노테이션들이 붙은 클래스들도 모두 스프링 빈으로 등록한다. 

 

@Component로 원하는 클래스를 스프링 빈으로 등록시키는 방법

등록하고 싶은 스프링 클래스 선언부 위에 @Component만 붙이면 된다. 

@Component
public class SpringServiceImpl {
	// code
}

⚠️인터페이스 말고, 구현 클래스의 선언부에다가 붙여야 한다.

 

지금까지 자동으로 스프링 빈을 등록해보았다. 

이제는 의존관계를 자동으로 주입해 보자. 

 

자동 의존관계 주입

@Autowired 라는 자동 의존관계 주입 어노테이션을 사용하면 된다. 

@Autowired를 @Component이 붙은 클래스의 생성자 위에다가 붙인다. 

(지금은 생성자 주입 방식을 사용해서 생성자 위에다가 붙일 건데, 다른 방법도 있다. )

@Component
public class SpringServiceImpl implements SpringService {
	
    private final SpringRepository springRepository;	// 해당 클래스가 의존하는 다른 클래스 객체
    
    @Autowired
    public SpringServiceImpl(SpringRepository springRepository) {
    	this.springRepository = springRepository;
    }
    
}

그러면 스프링 컨테이너에서, @Component 가 붙은 스프링 빈들 중 SpringRepository 타입의 빈을 찾는다.

('타입'으로 조회한다.) 

그리고 SpringServiceImpl이 호출될 때 그 찾은 스프링 빈을 매개변수로 넣어준다. 

 

⚠️

@Autowired를 쓸 때에는 반드시 @Autowired를 사용한 생성자를 가진 클래스(SpringServiceImpl)와 해당 생성자가 필요로 하는 매개변수 클래스(여기서는 SpringRepository의 구현 클래스인 SpringRepositoryImpl)에 모두 @Component 어노테이션이 붙어 있어야 한다. 

@Component
public class SpringRepositoryImpl implements SpringRepository {
	// code
}

 

 

이제 테스트를 만들어 보자.

: AutoAppConfigTest.java

 

1) 스프링 빈을 불러오기 위해서 AnnotationConfigApplicationContext 객체를 생성하자. 

2) 해당 객체 안에는 AppConfig 클래스 대신, 컴포넌트 스캔을 사용한 AutoAppConfig 클래스를 넣는다. 

3) AutoAppConfig 클래스로 SpringService 타입의 스프링 빈을 조회할 수 있는지 확인하자. 

public class AutoAppConfigTest {
	
    @Test
    void test() {
    	ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);
        SpringService springService = ac.getBean(SpringService.class);
        assertThat(springService).isInstanceOf(SpringService.class);
    }
    
}

 

일부 클래스를 제외하고 스프링 빈을 등록할 수도 있다. 

excludeFilters 옵션을 지정하면 된다. 

세부 옵션으로는 type, classes 등 여러 가지가 있다. 

@ComponentScan(
	excludeFilters = @ComponentScan.Filter( 
		type = FilterType.ANNOTATION, 
		classes = Service.class, 
	)
)
public class AutoAppConfig {
	// no code
}

: @ComponentScan의 대상 중, Configuration 어노테이션은 스프링 빈으로 포함시키지 않겠다는 의미. 

 

자동 주입 방식에서 빈 이름을 정하는 방법

빈의 기본 이름은 맨 앞 글자만 소문자로 바뀐 클래스 이름 이다. 

@Component
public class SpringRepositoryImpl implements SpringRepository {
	// code
}

: 빈 이름은 springRepositoryImpl 으로 등록된다. 

 

빈 이름을 지정할 수도 있다. 

@Component("지정하고_싶은_이름")

@Component("spring_repo")
public class SpringRepositoryImpl implements SpringRepository {
	// code
}

 

2. 탐색 위치와 기본 스캔 대상

컴포넌트 스캔을 할 때, 어느 위치부터 @Component 어노테이션을 탐색할지를 지정할 수 있다. 
basePackages 옵션으로 지정할 수 있다. 

@ComponentScan(basePackages = 'hello.core.member')
public class AutoAppConfig {
	// code
}

 

: hello.core.member 디렉토리 하위에 있는 파일들에서만 @Component를 탐색한다. 

 

기본값

아무런 값도 없다면, basePackage의 기본값은 @ComponentScan이 붙은 클래스의 바로 위 패키지가 된다. 
ex)

AutoAppConfig.java 클래스의 바로 위 폴더가 hello.core이라면,
: hello.core 아래의 파일들에 한해서 컴포넌트 스캔을 한다. 

 

AutoAppConfig.class 는 hello.core 디렉토리 바로 아래에 위치한다. 
-> hello.core 패키지(디렉토리) 아래에 있는 파일들만 탐색한다. 

@SpringBootApplication의 비밀

스프링 웹 어플리케이션을 실행할 때는 일반적으로 (패키지_이름)Application 클래스의 메인 메소드를 실행한다.

(여기서는 CoreApplication.java)
이 클래스는 기본적으로 최상단 패키지(여기서는 hello.core)의 바로 아래에 위치하는데, 그 이유가 있다. 

@SpringBootApplication 안에는 @ComponentScan이 포함되어 있다. 
: 실제 웹앱을 만들 때는 스프링에서 @ComponentScan을 명시적으로 사용할 일이 거의 없다. 어차피 메인 메소드에 붙어있는 @SpringBootApplication 어노테이션 안에 포함되어 있기 때문이다!


그래서 웹앱을 실행하면, 프로젝트 폴더에 있는 모든 스프링 빈들을 자동으로 등록할 수 있었던 것이다. 
<-> 만약 @SpringBootApplication이 붙은 메인 클래스가 특정 하위 디렉토리에만 속해 있었다면, 모든 빈을 불러올 수 없었을 것이다. 

 

컴포넌트 스캔의 대상

컴포넌트 스캔에서는 @Component만 스캔하지 않는다. 다른 어노테이션도 내부에 @Component를 포함하고 있기 때문이다. 
: @Controller, @Service, @Repository, @Configuration, ...

 

@Component을 상속하는 다른 어노테이션들

 

⚠️ 사실 어노테이션은 자바에서 지원하는 기능이 아니다.
⚠️ 한 어노테이션이 다른 어노테이션을 포함하는 상속 관계도 마찬가지다. 자바 언어는 어노테이션의 상속 관계 인식을 지원하지 않는다. 

 

어노테이션의 상속관계

보통 한 클래스가 다른 클래스를 상속하면, 상속받은 클래스에서 지원하는 기능 + a 를 수행한다. 

어노테이션도 마찬가지이다. 

 

@Component는 @ComponentScan이 어떤 클래스를 스프링 빈으로 등록할지를 알려주는 역할을 한다. 

그러나 @Component와 다른 어노테이션까지 같이 상속한 다른 어노테이션은 그 외의 부가적인 기능도 함께 제공한다. 

 

1) @Controller

스프링 MVC 컨트롤러로 인식한다. 


2) @Repository

스프링에서 데이터로 접근하는 클래스로 인식한다. 
또한 데이터 계층에서 예외가 발생했을 때, 이를 스프링 예외로 변환시킨다. 
✖️ 스프링 예외로 변환시킨다 = 예외를 추상화시킨다

 

3) @Service

특별한 처리를 하지 않는다. 개발자들이 비즈니스 계층을 인식하는 데 도움이 된다. 

 

3. 필터

컴포넌트 스캔에서 추가할 대상과 제외할 대상을 설정할 수 있다. 
: includeFilters, excludeFilters 옵션 사용

 

추가할 어노테이션, 제외할 어노테이션을 만들고 필터가 작용하는지를 직접 확인하기 위해 어노테이션을 만들어 보자. 


어노테이션을 만드는 방법

1) 인터페이스를 선언하고, 인터페이스 키워드 앞에 @을 붙인다.

public @interface IncludeAnnotation {

}

 

2) 해당 인터페이스 위에다 추가로 어노테이션을 더 설정할 수 있다. 

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IncludeAnnotation {

}

 

@Target : 해당 어노테이션이 어느 타입에 붙을지를 지정. 

ElementType.TYPE : 클래스 단위에 지정하는 어노테이션. 
TYPE이 아닌 다른 값으로 지정하면 어노테이션이 필드나 생성자에 붙게 할 수도 있다.

(ex. @Autowired)

방금은 컴포넌트 스캔에 추가할 어노테이션을 만들었다. 이제 제외할 어노테이션을 만들어 보자. 

: 같은 방법으로 ExcludeAnnotation을 만들자. 

 

테스트하기

위에서 만든 @IncludeAnnotation, @ExcludeAnnotation을 사용해서 테스트를 해 보자. 


1. BeanInclude와 BeanExclude 클래스를 만들고, 각각 @IncludeAnnotation, @ExcludeAnnotation을 붙이자. 
2. 테스트를 만들어 보자: ComponentFilterAppConfigTest.java
목적: includeFilter, excludeFilter 옵션이 잘 작동하는지 확인.

1) 테스트 클래스 안에 static class를 만들고, 해당 클래스를 @Configuration을 붙여서 스프링 빈 정보를 담는 클래스로 선언한다. 
2) 해당 클래스를 참고한 AnnotationContextApplicationConfig 객체를 만들고, 빈을 조회한다. 
3) 이때 BeanInclude(includeFilter)를 조회하면 조회가 되어야 한다. 
4) 반면 BeanExclude(excludeFilter)를 조회하면 에러(NoSuchBeanDefinitionException)가 발생해야 한다. 

 

에러는 최대한 구체적으로 명시하는 것이 좋은 테스트이다. 
<-> Exception.class -> 별로 도움 안 됨!

4. 중복 등록과 충돌

스프링 빈을 등록하는 방법으로 수동(AppConfig 클래스에 직접 등록)과 자동 등록(@ComponentScan 이용)이 있었다. 

수동 등록에서는 당연히 같은 이름의 빈이 2개 이상 있으면 충돌이 발생했다. 
그렇다면 자동 등록에서는 어떨까?


1) 자동 vs 자동

: ConflictingBeanDefinitionException이 발생한다. 


2) 자동 vs 수동

: 오류가 나지 않는다. 
수동 등록 빈이 자동 등록 빈을 오버라이딩(덮어 씌우기) 했기 때문이다. 
⚠️ 이름이 같을 경우, 자동 등록 빈보다 수동 등록 빈이 우선권을 가진다. 

그러나 이런 설정은 대부분 개발자의 의도적인 테스트에 의해 발생하기보다는, 수많은 설정이 꼬여서 발생하는 것이 대부분이다. 

따라서 스프링은 이후 자동 등록된 빈과 수동 등록한 빈의 이름이 같을 때, 어플리케이션을 종료(disable)시키는 방향으로 업데이트 되었다. 

 

disable 말고 overriding 시키는 방법

main>java>resources>application.properties

spring.main.allow-bean-definition-overriding=true

: 수동과 자동 등록된 빈 2개가 이름이 같을 때 overriding이 발생한다. 

(default : False)

 

 

싱글톤 방식의 주의점

 

무상태(stateless)로 설계해야 하는 이유

웹 어플리케이션 설계에서 유용한 싱글톤 방식을 사용할 때는 싱글톤 클래스를 반드시 무상태로 설계해야 한다. 다음 예제를 보자.

 

: StatefulService.java

: StatefulServiceTest.java

 

해당 코드에서는 ThreadA에서 userA가 주문을 하고, 가격을 확정하기 전에 ThreadB에서 userB가 다른 상품을 주문한다.

싱글톤 클래스라 클래스 안의 필드도 공유하고 있기 때문에, 이 경우 userA가 주문했던 금액과는 다른 금액이 나오게 된다.

무상태로 설계하기 위해서는 기존의 클래스 안의 멤버 변수(필드)를 지역 변수로 바꾸거나, ThreadLocal을 사용해야 한다.

 

지역 변수를 사용한 예시

StatelessService.java

더보기
package hello.core.singleton;

public class StatelessService {

    public int order(String name, int price){
        System.out.println("name = " + name + ", price = " + price);
        return price;
    }

}

StatelessServiceTest.java

더보기
public class StatelessServiceTest {

    @Test
    @DisplayName("싱글톤 클래스를 무상태로 설계해야 한다.")
    void statelessServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);

        StatelessService statelessService1 = ac.getBean("statelessService", StatelessService.class);
        StatelessService statelessService2 = ac.getBean("statelessService", StatelessService.class);

        int priceA = statelessService1.order("userA", 10000);
        int priceB = statelessService2.order("userB", 20000);

        assertThat(priceA).isEqualTo(10000);
        assertThat(priceB).isEqualTo(20000);

    }

    @Configuration
    static class TestConfig {

        @Bean
        public StatelessService statelessService() {
            return new StatelessService();
        }
    }

}

 

ThreadLocal이란?

스레드(Thread)의 개념

프로그램을 실행할 때, 스레드(thread) 프로세스(process)라는 말을 사용한다.

 

 프로세스: 운영체제로부터 자원을 할당받는 작업의 단위.

여러 개의 어플리케이션을 사용한다면, 여러 개의 프로세스(multi-process)가 동작 중이다.

 

 스레드: 프로그램(프로세스) 실행의 단위.

하나의 어플리케이션(프로세스)를 실행할 때도 여러 개의 스레드(multi-thread)를 사용할 수 있다.

 

 멀티 프로세스 -> 멀티 스레드

 멀티 스레드 -> 멀티 프로세스

 

변수의 생성 영역(메모리)

객체나 변수를 생성할 때 Heap영역, 또는 Stack영역에 위치시킬 수 있다.

 

 Heap: 모든 스레드에서 공유하는 영역.

ex) 정적(static) 변수

 Stack: 하나의 스레드에서 사용하는 영역.

ex) 지역(local) 변수

 

ThreadLocal의 구조

ThreadLocal스레드 정보를 key로 사용하여 값을 저장하는 Map 구조를 갖고 있다.

멀티 스레드 환경에서 Stateful 클래스를 싱글톤으로 선언하고 싶을 때, ThreadLocal을 사용하면 각 스레드 별로 다른 변수를 사용할 수 있다.

 

ThreadLocalService.java

더보기
package hello.core.threadlocal;

public class ThreadLocalService {

    private static ThreadLocal<String> product = new ThreadLocal<>();
    private static ThreadLocal<Integer> price = new ThreadLocal<>();

    public ThreadLocalService() {}

    public void setProduct(String item) {
        product.set(item); // set()으로 value 값 지정 가능
    }

    public void setPrice(Integer value) {
        price.set(value);
    }

    public void printOrder() {
        System.out.println("product = " + product.get() + ", price = " + price.get());
				// get()으로 value 값 반환 가능
    }
}

ThreadLocalServiceTest.java

더보기
package hello.core.threadlocal;

import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;


public class ThreadLocalServiceTest {

    @Test
    void threadLocalOrder() {

        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        ThreadLocalService tls = ac.getBean("threadLocalService", ThreadLocalService.class);

        Integer priceA = 10000;
        Integer priceB = 20000;
        String productA = "strawberry";
        String productB = "watermelon";

        Thread threadA = new Thread(()->{
            tls.setPrice(priceA);
            tls.setProduct(productA);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            tls.printOrder();

        });

        Thread threadB = new Thread(()->{
            tls.setPrice(priceB);
            tls.setProduct(productB);
            tls.printOrder();
        });

        threadA.start();
        threadB.start();

    }

    @Configuration
    static class TestConfig {

        @Bean
        ThreadLocalService threadLocalService() {
            return new ThreadLocalService();
        }
    }

}

 

ThreadLocal 사용할 때 주의점

⚠️ 메모리 문제

ThreadLocal은 사용 시 메모리를 관리해야 한다.

ThreadPool을 사용하면 기존에 생성했던 ThreadLocal을 재사용할 수 있는데, 이때 이전에 삭제되지 않은 메모리가 남아있을 수 있다.

ThreadPool을 사용할 경우 반드시 ThreadLocal.remove()를 이용하여 남은 데이터를 제거해 주어야 한다.

 

*참고한 포스트들*

 

@Configuration과 싱글톤

스프링 컨테이너는 @Configuration 으로 등록한 클래스에서 빈을 생성 및 조회하는데, 이 빈들을 모두 싱글톤 타입으로 만든다고 했다.

그런데 @Configuration으로 등록된 AppConfig 클래스의 각 빈 메소드에서는 new() 생성자로 객체를 생성한다.

어떻게 각 빈을 호출하면서 각 객체는 한 번만 생성하는 것이 가능할까?

 

: AppConfig.java

 

테스트를 위해서 각 구현 클래스(MemberServiceImpl.java, OrderServiceImpl.java)에 MemberRepository 객체를 조회할 수 있는 클래스를 임시로 작성하고, 결과를 보자.

 

: ConfigurationSingletonTest.java

 

모든 MemberRepository 객체의 주소값이 같다.

즉 MemberRepository 객체는 딱 한 번만 생성되고, 다른 클래스는 오직 하나의 객체를 공유하고 있다.

⇒ 싱글톤 패턴을 잘 준수한다.

 

AppConfig.java 파일을 변경하여, 각 빈이 호출될 때마다 로그를 남겨서 확인해 보자.

결과는 다음과 같다.

빈이 한 번씩만 호출되었다. 스프링 컨테이너는 인스턴스를 한 번 생성하고 나면 해당 빈 메소드를 더 이상 호출하지 않은 것이다.

 

@Configuration과 바이트코드 조작

빈이 한 번씩만 호출된 이유는 스프링 컨테이너가 바이트 코드를 조작해서, 사용자가 입력한 @Bean과는 다른 클래스를 만들고 그 클래스를 통해 싱글톤 패턴을 사용하고 있기 때문이다.

 

: ConfigurationTest.java

 

스프링 컨테이너는 싱글톤 패턴을 유지하기 위해서 사용자가 @Configuration에 등록한 코드를 조작해서 새로운 클래스를 만든다. 그 클래스가 스프링 빈으로 등록된 것이다.

 

바이트 코드 조작

바이트 코드란?

 바이트 코드

고급 언어(프로그래밍 언어. 자바 언어)로 작성된 코드를 가상머신(여기서는 JVM)이 이해할 수 있는 중간 단계의 코드로 컴파일한 언어.

기계어보다는 추상적(high-level)이고 고급 언어보다는 low-level이다.

 

코드 조작이 가능한 이유

🔑 바이트 코드를 조작하면 실행 결과가 달라지는 이유

자바 코드는 1차로 JVM이 처리할 수 있는 바이트 코드로 변환된다. 그리고 그 바이트 코드를 읽어서 비로소 프로그램이 실행된다.

바이트 코드 내용대로 실행이 되기 때문에, 중간 단계에서 코드가 조작된다면 결과값도 바뀌게 된다.

참고한 포스트: [Java] 바이트코드 조작 (tistory.com)

 

코드를 조작하는 방법(예시)

💡 byteBuddy 라이브러리

바이트 코드를 조작하는 코드를 작성하고, 결과를 다른 파일에 저장한다고 치자.

저장한 파일은 원본과는 다른 코드를 갖고 있다.

참고한 포스트: 자바 바이트 코드 조작하는 방법 (tistory.com)

 

바이트 코드 조작의 결과

아마도 바이트 코드를 조작해서, 이런 싱글톤 클래스를 만들었을 것이다.

  1. 사용자가 빈으로 등록한 클래스가 이미 스프링 컨테이너에 있다 → 해당 인스턴스 반환.
  2. 스프링 컨테이너에 사용자가 빈으로 등록한 클래스가 없다 → 새로운 인스턴스 생성.

 

*정리*

@Configuration은 싱글톤 패턴으로 빈을 생성하는 어노테이션이다. @Configuration이 없어도 빈은 생성된다! 다만 싱글톤 타입이 아니다.

 

@Configuration을 지우고 AppConfig.java 를 참고하여 코드를 실행한 결과는 다음과 같다:

 

생활코딩 express 강의 1강~22강을 듣고 정리한 내용입니다. 

https://youtu.be/hwknmhLKgYg

<목차>

1. Middleware(미들웨어) + Third-party middleware(제3자가 만든 미들웨어)

2. Routing(라우팅)

3. Error Handling(에러 처리하기)

4. Static contents(정적 컨텐츠 제공하기)

 

 

1. Middleware

1) 미들웨어란?

다른 사람(express 공식 개발팀 또는 제 3자)이 개발한 모듈 코드를 외부에서 가져와서 사용하는 것. 
미들웨어는 코드의 재사용성을 높여주고 코드를 간결하게 짤 수 있도록 도와준다. 

 

2) 미들웨어의 원리

const 변수 = require('미들웨어 이름');

app.use(미들웨어());

 

1. 미들웨어() 생성자를 통해서 미들웨어가 리턴된다. 
2. 리턴된 미들웨어는 app.use()를 통해서 앱에 장착된다. (= 앱에서 사용할 수 있다.)

 

(예시): helmet(웹 보안 문제의 취약점을 막아주는 미들웨어)

$ npm install helmet --save
const helmet = require('helmet');
app.use(helmet());

 

3) 미들웨어의 세분화

1) url

app.use('미들웨어 실행시킬 url', function); 

: 맨 왼쪽에는 해당 미들웨어를 실행시킬 url을 명시할 수 있다. 

app.use('/user', helmet());

: helmet() 미들웨어는 경로가 '/user'로 시작하는 경우에만 실행된다. 

('/user' 뿐만 아니라 '/user/password' 등의 '/user'을 포함한 하위 url에도 적용된다.)

2) http method

 

모든 메소드에 해당 미들웨어를 사용하고 싶다면 app.use(), 

특정 http method 요청이 들어왔을 때 해당 미들웨어를 사용하고 싶다면 메소드에 맞게 app.get(), app.post() 등을 사용하면 된다. 

 

app.use('/user', helmet());

위의 코드는 helmet() 미들웨어를 url이 '/user'로 시작하는 경우 실행하는 반면, 

아래의 코드는 url이 '/custom'으로 시작하면서 http get 방식의 요청이 들어온 경우에만 helmet() 미들웨어를 실행한다. 

app.get('/custom', helmet());

+

지금까지 라우팅에 사용하던

app.get('/url', (req, res)=>{}));

 

이 함수 역시 미들웨어의 종류 중 하나였다!

 

4) 미들웨어 연속적으로 주기

미들웨어 하나를 매개변수로 준 다음, 바로 다음에 실행될 함수를 추가 매개변수로 줄 수 있다. 
이 경우 매개변수로 주어진 순서대로 실행이 된다. 

app.get('*', (req, res, next)=>{
  console.log('first middleware')
  }),
  (req, res, next)=>{
    console.log('second middleware')
  }
});

 

5) 미들웨어 실행 순서

순서대로, 맨 위부터 실행한다. 
next()의 경우 다음에 실행될 미들웨어를 호출한다. 

만약 현재 실행되고 있는 미들웨어의 다음 매개변수로 주어진 미들웨어도 있고, 같은 url과 http method에 호출되는 미들웨어도 있는 경우:

app.get('/node/js', (req, res, next)=>{
  console.log('first middleware');
  next();		// option 1
  next('route');	// option 2
},
(req, res)=>{
  console.log('next middleware');	// option 1 result
}
);

app.get('/node/js', (req, res)=>{
  console.log('next route middleware');	// option 2 result
});

(둘 다 실행할 수 없다.)

 

 

1. next()를 실행하면 같은 미들웨어의 다음 매개변수로 주어진 미들웨어 실행
2. next('route')를 실행하면 다음 미들웨어가 실행된다.

 

+제3자가 만든 미들웨어(Third-party middleware)

사용하는 방법:

1) npm으로 패키지 설치

$ npm install cookie-parser

 

2) require를 통해 패키지 불러오기

const cookieParser = require('cookie-parser');

 

3) app.use()를 통해  해당 미들웨어를 app 인스턴스에 장착하기

app.use(cookieParser);

위 단계를 거치면, 각 라우터 미들웨어 안에다 부가적인 코드를 작성하지 않아도 미들웨어에 해당하는 http 방식과 url로 요청이 들어올 때 미들웨어가 활성화된다. 

 

[1] bodyParser

 

이 미들웨어는 클라이언트가 post방식으로 보낸 http request 중에서, body 부분을 parameter로 추출해 준다. 
그래서 post 방식에서 사용할 수 있다(사용자가 보낸 데이터가 있어야 한다)

// 필요한 코드가 생략된 sudo code
const fs = require('fs');
const bodyParser = require('body-parser');

app.use(bodyParser.urlencoded({extended: false}));

app.get('*', (req, res, next)=>{
  fs.readdir('./data', function(err, filelist){
    req.list = filelist;
    next();
  });
});

예시 코드에서, app.get() 라우터 미들웨어가 실행되기 전에 이미 body-parser 미들웨어가 실행되었고, 그 미들웨어는 filelist 변수에 실행값을 리턴한 상황이다. 

 

따라서 filelist 변수의 값을 request 객체의 list 속성에 추가하면(list 속성은 원래 있던 속성이 아니고, 임의로 추가한 속성이다), 후에 request 객체의 list 속성에 접근해서 body-parser 미들웨어가 추출한 값을 사용할 수 있다. 

 

[2] compression

웹서버가 클라이언트의 요청에 응답할 때 그 응답을 압축해서 보내는 것을 도와주는 미들웨어이다. (데이터의 양이 많으면 http 요청으로 보내기 어려울 수 있다.)


압축된 데이터는 기존 데이터에 비해서 크기가 작고, '개발자 도구'의 '네트워크' 탭에서 보면 어떤 방식으로 압축되었는지도 알 수 있다. 클라이언트(브라우저)는 해당 데이터를 받아서, 그 데이터가 압축된 방식을 사용해서 반대로 그 데이터를 해제(=압축 풀기)한 다음에 원본 파일을 볼 수 있다. 

 

 

+미들웨어 만들기

직접 미들웨어를 만드는 것도 가능하다. 
미들웨어는 함수이다. 다만 request, response, next 라는 3개의 매개변수를 받아야 한다. 

app.use((req, res, next)=>{

});

 

이렇게 입력해 주면 미들웨어를 만들고 만든 미들웨어를 앱 어플리케이션에 장착한 것이다. (=바로 사용할 수 있다)

req(request): 클라이언트가 보낸 요청

res(response): 서버가 보낼 응답

next(): 해당 미들웨어의 실행이 끝난 다음에 불러올 미들웨어이다. 
서버의 응답을 해당 미들웨어에서 끝낼 것이 아니라면, 반드시 next()를 통해 다음 미들웨어를 호출해 주어야 한다. 

 

 

2. Routing(라우팅)

1) 정의

데이터 통신에서의 라우팅(Routing)이란 네트워크상에서 주소를 이용하여 목적지까지 메시지를 전달하는 방법을 체계적으로 결정하는 경로선택 과정을 말한다. 이 과정을 능동적으로 수행하는 장치를 라우터(Router)라고 한다. 

 

즉 웹 서버로 http 요청이 들어오면, 해당 요청의 url 주소를 이용하여 요청을 체계적으로 처리하는 과정을 라우팅이라고 한다. 또한 이를 처리하는 미들웨어를 Router(라우터)라고 한다. 

(출처: 라우팅 (Routing) : 네이버 블로그 (naver.com))

 

2) url에 parameter를 실어서 보내는 방법

app.get('/:parameter-이름', (req, res)=>{
res.send(req.params);
});
: 후에 localhost:port-번호/parameter-값 url로 들어가면, 요청(request)의 매개변수(parameter/params)속성으로 json 타입의 객체가 반환되는 것을 볼 수 있다. 

이렇게(↓).
{"parameter 이름": "parameter 값"}

res.send() 또는 res.end()는 말 그대로 서버에서 응답을 종료한다는 의미이다. 

클라이언트에서 요청을 보내면 서버가 요청에 대해 응답하는 방식으로 통신이 일어난다. 

요청이 원활하게 이뤄지려면 서버는 응답을 완료한 뒤 응답이 끝났다고 명시, 표현해 줘야 한다. 

그 표시가 res.send() 또는 res.end() 메소드이다. 

만약 응답이 끝냈다고 명시해주지 않으면, 응답이 완료되지 않고 쭉 늘어지는 상황(hanging)이 발생한다. 

 

3) Router(라우터) - 주소 체계의 변경

복잡한 웹 서비스의 경우 수백 개의 api가 존재할 수 있는데, 그 api를 하나의 파일에 담는 것은 무리이다. 
여러 개의 api를 적절한 경로로 나눠서 복잡한 구조를 간단하게 하고 싶을 때 라우팅 작업을 사용한다. 

 

라우팅 처리하는 순서:

1. 공통된 url 경로를 가진 미들웨어들을 별도의 파일에 분리한다. 
2. app.js 파일(웹 어플리케이션이 실행되는 메인 역할의 파일)에다가 1번 파일의 주소를 변수로 불러온다.

(예시)

const indexRouter = require('./routes/index');
const topicRouter = require('./routes/topic');

 

3. 2번에서 선언한 변수(indexRouter, topicRouter)를 app.use()를 사용해서 app.js 파일에서 미들웨어로 사용하겠다고 선언한다. 이때 app.use('라우터와 연결할 url', 사용할 변수 이름); 으로 입력한다. 

app.use('/', indexRouter);
app.use('/topic', topicRouter);

 

4. 1번 파일에다가 express.Router(); 값을 갖는 변수를 선언한다. 이 변수가 라우터 역할을 할 것이다. 

const express = require('express');	// router 사용하기 위해서 필요한 express module 불러오기
const router = express.Router();

 

5. 1번 파일의 각 api(라우터 역할을 하는 미들웨어)에서 app으로 시작하는 미들웨어의 시작 부분을 2번 변수의 이름으로 바꾼다. 

 

(1~5번을 합한 예시)

# 공통된 url 경로를 가진 미들웨어들을 분리한 별도의 파일

const express = require('express');
const router = express.Router();
// 다른 미들웨어(express 공식 미들웨어/제 3자가 작성한 미들웨어)

router.get('/', (req, res, next)=>{
	// 미들웨어 코드
});
// 내가 작성한 기타 미들웨어 코드

module.exports = router;

# app.js 파일

const express = require('express');
const app = express();
const exampleRouter = require('./example/path');

app.use('/example/path', exampleRouter); // 다른 파일에서 사용한 라우터를 호출하는 코드
// app.js에 명시된 다른 미들웨어들

 

*단, app.js 파일에서도 미들웨어는 위에서부터 호출되기 때문에, app.use()로 다른 파일에서 사용한 라우터를 호출하는 코드는 위쪽에 배치되어야 한다. 

+

redirection 메소드:  주어진 url에 해당하는 페이지로 단순 이동하는 역할을 한다.

: response.redirect('이동하려는 url');

response.redirect('/example/url');

 

3. 에러 처리

express에서 에러를 처리하는 방법은 크게 두 가지가 있다. 


1. 코드의 맨 아래에다 에러를 처리할 미들웨어 작성하기.

미들웨어는 순서대로 실행되므로, 반드시 파일의 끝 부분에 작성해야 함!

이때 해당 미들웨어는 매개변수로 (err, req, res, next) 네 개의 매개변수를 받는다. 

(express에서는 그래야만 해당 미들웨어가 에러를 처리하는 역할을 하도록 정해 놓았다.)

// other middlewares

app.use((err, req, res, next)=>{
  // error handling
});

 

2. express에서 기본으로 제공하는 에러 처리 미들웨어 사용하기.

next() 함수의 매개변수로 err(에러)을 넣어 주면 된다. : next(err)

express에서는 별도의 처리를 하지 않아도, 해당 에러를 처리해 주는 미들웨어를 제공한다. 

router.get('/example/url', (req, res, next)=>{
  // code
  if(err){
    next(err);
  } else {
    // code
  }
});


4. 정적 파일(static file) 제공하기

express.static('정적인 파일이 있는 폴더의 경로');
: 해당 디렉토리에 해당하는 파일들은 정적인 파일들로 불러올 수 있다. 

app.use(express.static('정적인 파일이 있는 폴더의 경로'));
: 해당 디렉토리 안의 파일들을 웹 어플리케이션에서 정적 파일들로 사용할 수 있다. 

* 이 포스트는 인프런에 있는 김영한 님의 '스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술' 강의를 들으면서 내용을 정리한 글입니다. *

 

[메인 컨텐츠]

[무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 인프런 | 강의 (inflearn.com)

 

# 2022-03-04 #

 

#목차#

21. 순수 JDBC

22. 스프링 통합 테스트


21. 순수 JDBC

 

이번 시간에서는 데이터를 다루는 스프링 라이브러리 중 하나인 Spring JPA를 살펴보기 전, 이전에는 데이터를 어떻게 처리했는지를 보았다. 지금은 사용하지 않는 방법이므로 가볍게 보려고 한다. 

 

이전에는 'JDBC'를 사용했으며, (1) build.gradle 파일에서 implementation 선언을 한 뒤 (2) application.properties 파일에서 세부 설정을 해야 했다. 

 

+Jdbc(Java DataBase Connectivity)란, 자바에서 데이터베이스에 접속할 수 있도록 해 주는 API이다. 

 

1. 사전 환경설정

(1) build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	runtimeOnly 'com.h2database:h2'
}

이렇게 jdbc와 사용할 데이터베이스 h2를 import하는 과정이 필요하다. 

 

(2) application.properties

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa

해당 파일에서는 import를 한 뒤에 추가로 이뤄져야 하는 세부 설정을 관리한다. 예를 들면

[1] tcp(소켓이라고 한다. 여러 곳에서의 접속을 관리하는 용도라고 하는데, 후에 더 자세히 알아봐야겠다.)를 통해 어떤 경로로 접속할지를 명시하거나,

[2] Jdbc driver로 어떤 데이터베이스를 연결할지 해당 클래스를 명시하거나,

[3] H2 데이터베이스의 접근 권한을 받아내는 데 필요한 유저 이름, 비밀번호 등의 정보를 입력한다. 

이런 정보를 해당 파일에서 관리했다. 

 

2. DB 리포지토리 클래스 구현

앞서 DB를 사용하기 전에는 임시로 메모리를 사용하는 MemoryMemberRepository 클래스를 만들어서 DB를 대신하였다. 이제는 실제 DB에 연결했으므로 새로운 JdbcMemberRepository 클래스를 만들었다. 

(클래스 코드가 정말 복잡하고 길다. 지금은 이렇게 개발할 일이 없으므로 생략!)

 

리포지토리를 구현했다 치면, 이제는 리포지토리 역할을 해 오던 MemoryMemberRepository 클래스를 JdbcMemberRepository 클래스로 교체해 주면 된다. 

 

앞서 의존성 주입(DI)와 스프링 빈 등록 과정, DB 인터페이스를 사용하면 단 한 줄의 코드만 바꿔서 이 작업을 대신할 수 있다. 

@Bean
public MemberRepository memberRepository(){
    return new JdbcMemberRepository(dataSource);
    // 기존 코드에서는 MemoryMemberRepository()였다. 
}

또한 앞에서 궁금했던 '형 변환'의 이유도 여기서 밝혀졌다. 

형 변환이란 하위 클래스의 인스턴스를 생성할 때, 그 클래스의 타입을 자신의 상위 클래스나 인터페이스 타입으로 선언하는 것을 의미한다.

아래의 코드가 그 예시이다. 

MemberRepository memberRepository = new JdbcMemberRepository();

이처럼 해당 인스턴스를 상위 클래스나 인터페이스 타입으로 선언하면, 후에 구현체나 하위 클래스를 바꿀 때 해당 코드만 바꿔 주면 된다. (하위 클래스들의 메소드들은 모두 상위 클래스/인터페이스에게서 상속/구현받았다고 전제할 때 해당한다.) 

이를 '다형성'이라고 한다. 그때그때 필요에 맞춰서 해당 인스턴스가 다양한 타입으로 변화할 수 있어서 다형성이라고 말하는 것 같다. 

 

또한 위처럼 인터페이스와 다형성을 사용한 개발은 개발의 SOLID 원칙 중 하나인 개방-폐쇄 원칙(Open-close principle)을 지켰다고 볼 수 있다. 

폐쇄적으로 부분의 코드만 수정해도 코드가 프로그램 전반에 개방적으로 영향을 줄 수 있다는 의미인 듯 하다. 


22. 스프링 통합 테스트

통합 테스트와 단위 테스트

스프링 통합 테스트란, 테스트 시 스프링 환경을 사용하는 테스트이다. 스프링 통합 테스트에서는 스프링과 연결된 다른 프로그램들을 전체적으로 사용한다. 앞서 스프링과 DB를 연결하였으므로, 여기서는 DB와 연결된 테스트를 진행할 수 있다. 

통합 테스트의 반대말은 단위 테스트(unit test)이다. 강의에서는 단위 테스트가 대체로 더 좋은 테스트이고 에러를 잘 잡아낼 수 있는 테스트라고 언급했다. 

 

스프링 통합 테스트를 사용하기 위해서는 해당 테스트 클래스에다가 @SpringBootTest 어노테이션을 붙여 준다. 그러면 이 클래스 안의 테스트들은 스프링 환경에서 실행된다. 

 

실제로 통합 테스트를 실행할 때

또한, 현재 프로그램은 DB와도 연결되어 있기 때문에 DB에 있는 기존 데이터가 테스트의 결과에 영향을 줄 수 있다. 

이를 방지하기 위해서 실제 테스트를 할 때에는

(1) 테스트 전용 DB를 따로 만들어서 실행한다. 

(2) @Transactional 어노테이션을 사용하면 개별 테스트를 실행할 때마다 테스트에서의 변경 사항을 DB에 저장하지 않고 롤백(roll-back), 즉 쿼리가 실행되지 않은 상태로 되돌려 준다.

 

DB의 쿼리 처리

@Transactional은 DB가 쿼리를 처리하는 방식과 관련이 있다. 

DB는 SQL문 등의 쿼리를 받자마자 실행하는 것이 아니다. 쿼리를 받고, 커밋(commit)을 해야만 해당 내용이 실행되어 DB 내부에 변화가 일어난다. 

따라서 Transactional은 엄밀히 말하면 '쿼리 실행 전으로 상태를 되돌리는 것' 이 아니라, '쿼리를 쌓아두고(커밋하지 않고) 있다가 테스트가 끝나면 종료시키는 것'에 가깝다.

 

+뿐만 아니라, 테스트 환경에서도 필드나 생성자에 @Autowired 어노테이션을 설정할 수 있다.

 

* 이 포스트는 인프런에 있는 김영한 님의 '스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술' 강의를 들으면서 내용을 정리한 글입니다. *

 

[메인 컨텐츠]

[무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 인프런 | 강의 (inflearn.com)

 

# 2022-03-04 #

 

#목차#

18. 회원 웹 기능 - 등록

19. 회원 웹 기능 - 조회


18. 회원 웹 기능 - 등록

 

폼(form)을 사용해서 회원가입을 위한 이름을 입력값으로 받고, 이를 저장소에 반영하는 기능이다. 

여기서는 MemberForm.java 파일을 따로 만들어서 폼 기능을 구현하였다. 

+ django에서는 forms.py로 아예 폼들을 따로 모아서 models.py에 있는 모델과 분리시키던데, 스프링에서도 이런 방법이 자연스러운 것인지는 아직 잘 모르겠다. 

 

여느 폼 기능과 똑같이, 폼 클래스에서는 받아야 할 입력값들을 private한 필드로 선언한다. 그리고 getter, setter 메소드만 public으로 선언한다. 

 

여기서는 REST API 방식 대신 MVC 방식을 사용하여 각 페이지/기능에 맞는 html 페이지가 따로 존재했다. 

 

HTTP 요청 주고받는 순서

1. HTTP GET 방식으로 form을 제공하는 페이지에 접속한다.

2. 필요한 값을 입력하고 등록 버튼을 누른다. 

3. 해당 폼은 html의 form 태그 안에서 만들어진 폼이며, 이 경우 form 태그는 action=""으로 입력된 폼을 어떤 주소로 보낼지에 대한 정보를 담고 있다. 

등록 버튼을 누른 순간 해당 url 주소로, HTTP POST 방식으로 폼이 전송된다. 

4. html의 form 태그에 명시된 해당 주소로 가면 POST 방식으로 들어온 요청을 처리하는 메소드가 있다(있다고 가정한다).

5. 해당 메소드에서는 매개변수로 MemberForm 객체를 받고, 해당 객체의 .getName() 등의 메소드(MemberForm 내부에서 정의된 메소드)를 통해서 사용자가 입력한 값에 접근하고 추가적인 처리를 할 수 있다.


19. 회원 웹 기능 - 조회

 

조회 기능은 GET 방식으로 이뤄지며, 여기서는 저장소 역할을 하는 List 객체 안에 저장된 모든 member의 정보를 꺼내보는 기능이다. 

 

즉 데이터 저장소(여기서는 실제 DB가 아니지만 DB와 동일하다고 봐도 무방하다)에 접근해야 하는데, 그러려면 컨트롤러(controller) 클래스에서 리포지토리(repository)의 기능을 사용해야 한다. 

 

다행히 앞서 컴포넌트 스캔 또는 자바 방식으로 스프링 빈을 직접 등록함으로써 이 문제는 이미 해결되었다.

싱글톤 패턴을 사용하면서 컨트롤러는 서비스에, 서비스는 리포지토리에 의존하고 있기 때문이다. 

 

따라서 컨트롤러 클래스에서는 private 타입으로 선언한 서비스 클래스 객체를 호출한 다음, 해당 객체가 가진 join() 메소드를 호출하면 된다.

그러면 서비스 클래스에서는 private 타입으로 선언한 리포지토리 클래스 객체를 갖고 있기 때문에, 결과적으로 데이터를 저장하는 객체에 접근해서 데이터를 꺼내올 수 있다. 

 

다만 현재는 웹 MVC 방식을 사용하기 때문에 스프링에서는 직접 데이터를 List 형태로 꺼내지 않고 웹 템플릿에 넘긴다. Spring에서는 이럴 때 Model 객체를 사용한다. 

즉 Model 타입의 객체를 매개변수로 받아서, 해당 model 객체에 addAttribute()로 속성을 추가한다. 이후 템플릿 이름을 호출하면 해당 model 객체는 호출한 템플릿에서 변수로 사용될 수 있다. 

 

이로써 간단한 등록 및 조회 기능을 구현하였지만 한계가 있다. DB나 파일 등을 사용해야만 서버를 껐다가 켜도 해당 데이터가 남아 있다. 따라서 다음 시간에는 Spring에서 DB를 사용하고 연결하는 방법에 대해 포스팅하려고 한다. 

 

* 이 포스트는 인프런에 있는 김영한 님의 '스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술' 강의를 들으면서 내용을 정리한 글입니다. *

 

[메인 컨텐츠]

[무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 인프런 | 강의 (inflearn.com)

 

# 2022-03-04 #

 

#목차#

15. 컴포넌트 스캔과 자동 의존관계 설정

16. 자바 코드로 직접 스프링 빈 등록하기


15. 컴포넌트 스캔(Component scan)과 자동 의존관계 설정

 

컴포넌트 스캔이란?

컴포넌트(@Component)는 스프링에서 관리하는 클래스 앞에 붙는 annotation(@)이다. @Component가 붙은 클래스는 자동으로 스프링이 관리한다. 앞서 컨트롤러 클래스에는 @Controller라는 annotation을 붙였는데, 이것도 컴포넌트에 해당한다. (@Controller annotation 클래스를 보면 해당 클래스에도 @Component annotation이 붙어있기 때문이다.)

 

그런데 스프링이 클래스들을 관리하는 방법은 컴포넌트 스캔뿐만이 아니다. 자바 코드를 사용해서 직접 스프링 빈(@Bean)을 등록할 수도 있다. 강의에서는 각각의 장단점이 있고 섞어서 사용할 수도 있으니 어쨌든 두 방법을 모두 알긴 알아야 한다고 하셨다. 

 

우선 컴포넌트 스캔 방법부터 다뤄보자. 앞서 컨트롤러, 서비스, 리포지토리 클래스를 만들었다. 이 클래스들 역시 각자의 역할이 있고(컨트롤러-서비스-리포지토리 방식 구성), 따라서 컴포넌트 스캔을 할 수 있다. 정확히 말하면, 이 [컨트롤러-서비스-리포지토리] 구성이 정말 자주 사용되는 구성이다 보니 스프링에서는 각각의 구조를 위한 annotation이 따로 있는 것이다. 

 

그래서 서비스 클래스에는 @Service, 리포지토리 클래스에는 @Repository annotation을 붙여주면 스프링에서 이 클래스들을 관리하고 필요할 때 알아서 배치할 수 있다. 

 

컴포넌트 스캔이 필요한 이유

그런데 앞서 작성한 코드로도 충분히 컨트롤러-서비스-리포지토리의 구조를 갖춘 것 같은데 왜 컴포넌트 스캔이 필요할까? '자동 의존관계 설정(Dependency Injection. DI)' 때문이다. 

 

해당 구조에서는 컨트롤러는 서비스에게, 서비스는 리포지토리에게 '의존'한다. 즉 컨트롤러의 메소드는 서비스가 없으면 작동할 수 없고, 서비스의 메소드도 리포지토리가 없으면 실행될 수 없다. 이러한 관계를 의존관계(Dependency)라고 한다. 그래서 컨트롤러나 서비스의 생성자에 매개변수로 서비스나 리포지토리 타입의 매개변수를 넣는 식으로 이 의존관계를 해결했던 것이다. 

 

그런데 스프링에서 컴포넌트 스캔을 하면 이 과정을 스프링이 알아서 해 준다. 직접 할 필요가 없다는 것이다. 

 

컴포넌트 스캔하는 방법/과정

(1) 각 클래스에게 역할에 맞는 annotation을 붙인다. 

(2) 클래스 사이에 의존관계가 있는 경우가 있다. 

A 클래스가 B 클래스에게 의존한다고 해 보자. 이때 A 클래스는 자신의 생성자를 만들 때 B 클래스 타입의 매개변수를 받아 와서 new() 방식으로 B 클래스에게 의존한다. (뒤에서 다루겠지만 생성자를 만드는 것 말고 다른 의존 방법도 있다. )

위와 같은 기존 방식과 달리, 컴포넌트 스캔에서는 new()를 쓰지 않는다. 왜냐하면 객체의 생성을 스프링에서 관리하기 때문이다. 그냥 A 클래스의 생성자로 B 클래스 타입의 매개변수를 받는 것은 동일한데, 다른 점은 new()를 쓰지 않고 싱글턴 패턴으로 객체를 관리해 주며, 해당 생성자 위에 @Autowired이라는 annotation을 붙인다는 것이다. 

 

싱글턴 패턴을 쓰는 이유는 굳이 Service 등의 클래스를 여러 개 만들 필요가 없기 때문이다. 또한 여러 객체가 만들어지는 경우 그 안의 필드, 리소스들이 서로 동일하지 않게 되어 문제가 발생할 가능성도 커진다. 

 

[1] 컴포넌트 스캔 미사용

// service.java
public class Service{
	private Repository repository;
    
    public Service(Repository repository){
    	this.repository = repository;
    }
    
    // methods
    
    // example method
    public void example(){
    	service = new Service(new Repository());
    }
    
}

// repository.java
public class Repository{

	// methods

}

[2] 컴포넌트 스캔 사용

// service.java
@Service
public class Service{
	private Repository repository;
    
    @Autowired
    public Service(Repository repository){
    	this.repository = repository;
    }
    
    // methods...
}

// repository.java
@Repository
public class Repository{
	// methods...
}

 

 

의존관계 주입하는 여러 방법

의존관계 주입에는 크게 3가지 방법이 있다. (1) 생성자 주입, (2) 메소드(setter) 주입, (3) 필드 주입이다. 

그러나 생성자 주입이 편리하고 단점도 적어서(없는건지 적은건지 모르지만 셋 중에는 가장 좋다.) 주로 생성자 위에 @Autowired를 붙인다. 

 

메소드 주입을 잘 하지 않는 이유

메소드 주입은 setter 메소드의 매개변수로 의존하는 클래스 타입의 변수를 받아서 할당하는 방식이다. 그러나 보통 스프링 서버를 한번 로딩하고 나면 굳이 변수를 여러 번 할당하거나 바꿔 낄 일이 없다. (예를 들어서, 서비스 객체에 리포지토리를 여러 번 바꿔 끼울 일이 없다. ) 그래서 굳이 만들 필요가 없다. 또한 이런 메소드를 만들 때는 public으로 선언해야 하는데, 만일 다른 곳에서 setter 메소드를 잘못 사용하면 중간에 다른 리포지토리가 할당되는 등의 오류가 발생할 수 있기 때문에 권장하지 않는다. 

 

필드 주입을 잘 하지 않는 이유

중간에 바꿔 낄 수 없고, 인텔리제이 등의 IDE에서도 권장하지 않는다는 방식으로 밑줄이 그어진다. 


16. 자바 코드로 직접 스프링 빈 등록하기

 

그런데 컴포넌트 스캔을 사용하지 않고 직접 스프링 빈을 등록하는 방법이 있다. 

 

직접 등록하는 방법의 이점

-리포지토리 등 클래스를 '바꿔 낄 일'이 생길 때 필요하다. (ex. DB를 여러 개 사용하는 경우, 다른 DB로 변경하는 경우 등등)

 

컴포넌트 스캔과 비교했을 때의 단점

-컴포넌트 스캔 코드가 훨씬 간결하다. 

-또한 컴포넌트 스캔을 아예 안 쓸 수는 없다. @Service, @Repository annotation은 스프링 빈으로도 등록할 수 있지만, @Controller annotation은 사용해야만 스프링에서 컨트롤러로 인식할 수 있다. 

 

스프링 빈 등록하는 방법

(1) Config 클래스를 SpringBootApplication 클래스와 같은 디렉토리에 만든다. 

(2) 해당 클래스 위에 @Configuration annotation을 붙인다. -> 스프링에게 @Bean들을 찾을 때 여기서 찾으라고 표시해 주는 역할인 것 같다.

(3) 해당 클래스 안에다가는 @Bean을 사용해서 생성자들을 등록한다. 

 

예시 코드

@Configuration
public class Config{
	
    @Bean
    public Service service(){
    	return new Service(repository());
    }
    
    @Bean
    public Repository repository(){
    	return new Repository();
    }
    
}

 

'server-side > spring' 카테고리의 다른 글

spring 개발일지 21-22강  (0) 2022.03.08
spring 개발일지 18-19강  (0) 2022.03.08
spring 개발일지 10-12강 + 개발 고민주제 틈틈이 정리  (0) 2022.03.04
spring 개발일지 5-6강  (0) 2022.03.03
spring 개발일지 4강  (0) 2022.03.03

+ Recent posts