단순 공부를 하고 프로젝트를 진행할 때는 Tomcat이 내장되어있는 Jar파일 배포가 실행면에서 편리합니다.

그러나, 회사 업무로 프로젝트를 진행하며 인프라 쪽에서 Tomcat을 별도 관리하고 내부에 암호화 모듈을 설치 등 Tomcat에 별도의 작업을 진행하는 경우가 생기면서 기존 프로젝트의 빌드 및 배포를 Tomcat이 내장되어있지 않은 War 방식으로 바꿔야 했습니다.

 

Jar 파일과 War파일의 차이점을 정리해 봅니다.

 

 

1. Jar? War?

2. 기존의 Jar 프로젝트를 War파일 빌드 형식으로 바꾸기

3. .war 파일 생성 및 테스트

4. 추가


1. Jar? War?

1) .Jar(Java Archive)

jar는 Java 어플리케이션용 아카이브 파일입니다. 보통 배치 프로그램이나 CLI기반 Java 어플리케이션에서 사용됩니다.

Java 클래스 파일(.class), 관련 리소스(프로퍼티 파일, 설정 파일 등), 의존성 라이브러리를 포함합니다.

(실행 가능한 jar 파일의 경우 MANIFEST.MF) 파일에 메인 클래스 경로가 지정됩니다.

 

구성 디렉토리 구조는 아래와 같습니다.

testApp.jar
│
├── com/example/Main.class (메인 클래스)
├── resources/
│	└── application.properties
└── META-INF/
	└── MANIFEXT.MF (메인 클래스 지정)

그리고 이렇게 생성된 jar파일은 java -jar 명령어로 실행됩니다.

Spring Boot 기반 어플리케이션은 보통 jar 파일로 배포되며, 내장 WAS(Tomcat, Jetty 등)을 포함해 웹 어플리케이션을 실행할 수 있습니다.

 

2) .War(Web Application Archive)

war는 웹 어플리케이션용 아카이브 파일입니다. 주로 서블릿 컨테이너나 WAS에 배포하기 위해 사용됩니다.

JSP파일, HTML, CSS, JavaScript, 이미지 리소스, 서블릿 클래스 파일 등을 포함하며, 표준 디렉토리 구조(WEB-INF, META-INF 디렉토리)를 따릅니다.

testApp.war
│
├── intex.html (메인 클래스)
├── css/
├── js/
├── WEB-INF/
│	├── web.xml (배포 서술자)
│	└── classes/ (컴파일된 서블릿 클래스 파일)
│	└── lib/ (프로젝트에 필요한 JAR 파일)

Tomcat, JBoss, WebLogic 등 WAS 환경에서 실행되며, 웹 브라우저를 통해 사용자가 어플리케이션에 접근합니다.

구분 .war .jar
용도 웹 어플리케이션 배포용 독립 실행형 Java 프로그램 배포용
구성 JSP/HTML/CSS/서블릿 등 포함 Java 클래스, 리소스 파일 포함
실행 방식 WAS(Tomcat, JBOSS 등)에서 실행 java -jar로 실행 가능
주 사용 기술 서블릿/JSP 기반 Spring Boot, CLI, 배치 프로그램

2. 기존의 Jar 프로젝트를 War 파일 빌드 형식으로 바꾸기

1) SpringBootServletInitializer 추가

: War 배포 시 Spring Boot 어플리케이션이 외부 WAS에서 실행되도록 초기화 설정이 필요합니다.

(기존에 main 메서드로 어플리케이션 로직 시작을 실행했던 것 X, 초기화 설정을 추가해 어떤 클래스파일이 어플리케이션 소스인지 명시해 줍니다.)

public class ServletInitializer extends SpringBootsServletInitializer{
	@Override
    protected SpringApplicationBuilder configuer(SpringApplicationBuilder application){
    	return application.sources(MyApplication.class);
    }
}

 

2) 내장 WAS 비활성화

: 내장된 Tomcat은 .war 배포 시 필요하지 않으므로, 이를 provided로 설정합니다.

pom.xml 파일의 해당 설정을 바꿔줍니다.

 

3) 필요에 따라 web.xml 설정을 작성합니다.

: 외부 WAS에서 web.xml 파일을 필요로 하는 경우 작성합니다.

보통 Spring Boot 기반의 WAR 배포에서는 필요하지 않으나, 외부 WAS(Tomcat, JBoss, WebSphere 등)에서 특정 기능을 사용해야 하는 경우 web.xml이 필요할 수 있습니다.

- 서블릿 2.x 또는 3.0 이하 환경 (구형 WAS)

- 보안 필터 또는 인증 설정이 필요한 경우(외부 WAS에서 인증, 세션 관리 등을 설정하려는 경우)

- 외부 WAS에서 특정 리스터 또는 필터를 강제해야 할 경우(예: CORS, 인코딩 필터 등)

- 세션 타임아웃 설정이 필요한 경우(WAS 설정 파일에서 지정할 수 있지만, 어플리케이션 단위에서 설정하려는 경우)

- DispatcherServlet을 수동을 등록해야 하는 경우(Spring Boot가 아니라 Spring MVC 기반의 웹 어플리케이션에서)

 

이 외에 대부분의 Spring Boot + 서블릿 3.0 이상에서는 Web.xml을 필요로 하지 않습니다.


3. .war 파일 생성 및 테스트

: 메이븐 빌드도구를 사용한다면 해당 프로젝트 폴더에서 mvn clean package -Dskiptests(선택)을 통해 빌드를 실행합니다.

빌드가 완료되면 프로젝트 폴더의 target/디렉토리 안에 .war 파일이 생성됩니다.

이 .war 파일을 사용할 Tomcat의 webapps 폴더에 옮겨넣고, Tomcat을 재시작하면 됩니다.

 

[내가 사용하는 방법]

1. Tomcat경로/bin 폴더에서 ./shutdown.sh를 입력해 톰캣을 완전히 종료합니다.

2. Tomcat경로/logs 폴더의 로그 파일을 모두 삭제합니다.

3. Tomcat경로/webapps에 .war파일과 압축이 풀린 해당 파일명의 폴더까지 삭제합니다.

4. 새 .war 파일을 webapps에 옮기고 Tomcat경로/bin폴더에서 ./startup.sh를 실행합니다.

 

이후 정상적으로 실행 되었는지 디버깅이 필요하면 비워둔 logs폴더에 새로 생성된 로그 파일들을 확인합니다.

빌드 파일명을 'ROOT.war'로 만들기

pom.xml 파일의 build설정 항목에 finalName을 지정할 수 있습니다.

이곳에 이름을 ROOT로 지정하면, war파일을 빌드했을때 빌드파일의 이름이 ROOT.war로 빌드가 됩니다.

(기본적으로는 artifactId-version.war 형식으로 빌드됩니다.)

 

ROOT.war를 사용하는 이유

일반적으로 Tomcat에서 .war 파일을 배포할 때, ROOT.war로 설정하면 기본 컨텍스트(/ 경로)로 배포가 됩니다.

이게 무슨 말이냐 하면, 기본적으로 설정되는 artifactId-version.war 형식으로 배포를 하면 기본 경로가 http://example.com/artifactId-version/으로 설정됩니다.

이것을 ROOT.war 이름으로 배포해 webapps 폴더에 넣으면

http://exaple.com/ 경로로 기본 경로를 사용할 수 있습니다.

 

물론 .war 파일 이름을 ROOT.war로 하지 않고도 기본 컨텍스트를 지정할 수 있습니다.

1. conf/server.xml 설정 변경하기

    - Tomcat경로/conf 폴더에 server.xml 폴더를 열어 컨텍스트 경로를 직접 지정할 수도 있습니다.

<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
    <Context path="/" docBase="myapp" />
</Host>

    이렇게 하면 myapp.war 파일을 사용하면서도 컨텍스트 경로는 /(루트)로 설정할 수 있습니다.

2. webapps/ROOT/ 폴더에 직접 배포하기

    - Tomcat경로/webapps폴더 안에 ROOT 디렉토리를 만들고, 빌드한 .war파일을 안에 압축을 해제합니다.


4. 추가

Root.war 파일과 ROOT.war를 조심하시오.

Tomcat은 기본 컨텍스트(/ 경로)에 배포할 .war파일의 이름을 ROOT.war로 인식하기 때문에, 서버에 scp 명령어를 사용해 배포파일을 올리다가 Root.war로 올려버리면 Tomcat은 이를 올바르게 배포하지 못하고 무시하거나 동작이 잘못될 수 있습니다. 🥲🥲

자주 사용되는 객체나 메서드, 설정에 대해서는 Spring Configuration을 이용해 빈(Bean)을 스프링 컨테이너에 등록해 사용합니다.

 

그러나 환경 변수를 선언하고 이를 빈 컨테이너에 등록해 사용하는 방법도 있습니다.

프로젝트를 진행하는 도중 여러 환경 변수가 필요한 때가 있습니다.

(예: OAuth2.0 로그인을위해 필요한 시크릿 키를 설정해야 할 때, 혹은 Google SMTP를 사용하기위해 포트번호나 username, password의 등록이 필요할 때, CORS 설정을 config 이곳 저곳에 해 두었는데 여기 할당할 도메인이나 아이피를 전역변수로 편하게 관리하고 싶을 때 등)

 

이를 위해 스프링 프로젝트의 설정파일인 .properties 혹은 .yml에서 환경변수 설정방법을 알아봅니다.

 

아래와 같은 흐름으로 설명합니다.

1. .properties? .yml?

2. 환경변수 설정하기

3. 환경변수 사용하기


1. .properties ? .yml ?

두 파일형식 모두 보통 application.properties 혹은 application.yml의 형태로 프로젝트 경로(src/main/resources)에 포함되게 됩니다. 딱히 Maven이나 Gradle처럼 빌드도구를 달리한다고 해서 파일형태식이 지정되는 것이 아니라, 프로젝트의 성격에 따라 개발자가 프로젝트 생성 단계에서 정하게 됩니다.

 

두 파일형식의 차이는 아래와 같습니다.

 

a. properites 파일형식

  • Key-Value pair 형식: 키와 값을 = 또는 ;로 구분합니다.
  • 계층구조의 표현은 .을 통해 나타냅니다.
  • 주석은 #을 사용합니다.
  • 배열을 나타낼 때는 중복된 키를 사용합니다.
# 서버설정 properties
server.port=8080
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=admin
spring.datasource.password=secret

# 배열
spring.datasource.config[0]=jdbc:mysql://host1:3306/db1
spring.datasource.config[1]=jdbc:mysql://host2:3306/db2

 

 

b. yml 파일형식 (YAML: YAML Ain't Markup Language)

  • :으로 키와 값을 구분합니다.
  • 계층구조의 표현은 들여쓰기를 통해 나타냅니다.
  • 주석은 #을 사용합니다.
  • 배열을 나타낼 때는 -기호를 사용합니다.
# 서버설정 properties
server:
  port: 8080

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: admin
    password: secret
    
# 배열
spring:
	datasource:
    	config:
            - jdbc:mysql://host1:3306/db1
            - jdbc:mysql://host2:3306/db2

 

- Spring boot에서는 환경별 설정파일을 이용해 환경에 따라 다른 설정을 적용할 수 있습니다.
(예: 로컬개발환경에서의 파일경로설정 → C:\...(윈도우), 배포환경에서의 파일경로설정 → /home/user/...(리눅스))

 

.properties 파일형식을 사용하는 경우, 다음과 같은 규칙으로 환경별 설정 파일을 정의할 수 있습니다.

(한 파일에서 관리할 수도 있으나, 개별파일로 작성하는것이 관리에 더 유리합니다.)

application-{profile}.properties

  • application.properties: 기본 설정
  • application-dev.properties: 개발 환경
  • application-prod.properties: 운영 환경
  • application-test.properties: 테스트 환경

이후 기본설정파일에 spring.profiles.active=dev 형식으로 작성해 어떤 환경의 설정파일을 적용할 지 정할 수 있습니다.

(중복되는 설정이 있다면 기본 설정위에 활성화된 설정파일이 덮어씌워집니다.)

.jar 파일을 실행할땐 CLI환경에서 java -jar 파일명.jar -Dspring.profiles.active=dev 형식으로 설정파일을 적용해 실행할 수 있습니다.

 

.yml 파일형식을 사용하는 경우에도 환경별 설정을 한 파일에서 혹은 개별 파일로 관리할 수 있습니다.

  • application.yml: 기본 설정
  • application-dev.yml: 개발 환경
  • application-prod.yml: 운영 환경
  • application-test.yml: 테스트 환경

어떤 기준으로 설정파일 형식을 정하고, 어떻게 관리할 것 인가 ?

  • 환경별 설정 파일 개수가 많아지는 경우 → 파일을 개별로 나누어 관리하는 것이 용이합니다.
  • properties 형식과 yml 형식의 차이는 다음과 같습니다.
    • 개별 파일 관리가 기본적이고 권장되는 경우 → properties 형식: 계층구조 표현이 키-값 형태라 설정이 복잡해지는 경우, 하나의 파일에 모든 설정을 작성하면 읽기가 굉장히 어려워집니다.
    • yml형식: 계층구조 표현이 읽기 용이해 하나의 파일 안에서 관리가 쉬울것으로 판단되는 경우. 그러나 계층구조 표현이 읽기 쉬워도, 설정이 많아지면 개별파일로 관리하는 것이 용이합니다.
Relaxed Binding
: 위의 예시들에서 모든 환경변수가 소문자로 작성되었는데, 본래는 Windows OS에서는 대소문자와 . _ 구분에 상관없이 환경변수에 따른 설정이 잘 적용됩니다.
그러나 Linux/Mac OS에서는 환경변수이름을 대문자로 작성해야하며 .대신 _기호를 사용하는 것이 안전합니다.
다만, Spring Boot에서 지원(2.4 이상에서 더 강화되었음)하는 Relaxed Binding 기능이 다양한 형식의 매핑을 지원합니다. Docker 컨테이너 역시도 대소문자에 관대합니다.
다만, 권장사항은 '환경변수는 대문자에 _기호를 조합해 작성하는 것이 가장 안전.' 입니다.

2. 환경 변수 설정하기

환경변수는 파일형식에 따라 아래와 같이 설정할 수 있습니다.

# properties 파일 환경변수 설정
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=admin

# yml 파일 환경변수 설정
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: admin

사용할 환경변수의 성격에 따라 개발자 임의로 이름을 짓고, 값을 할당할 수 있습니다.

 

그런데, 이미 존재하는 환경변수에 임의로 작성하게 된다면 어떻게 될까요 ?

예를들어, spring.jpa.hibernate.ddl-auto라는 JPA관련 환경변수 설정과 개발자가 임의로 작성할 환경변수 이름이 겹친다고 가정해보겠습니다.

 

Spring Boot는 설정값을 가져올 때 아래와 같은 순서를 따릅니다.

  1. 명령줄 인자(--spring.jpa.hibernate.ddl-auto=none)
  2. JVM 옵션(-Dspring.jpa.hibernate.ddl-auto=none)
  3. 환경변수(SPRING_JPA_HIBERNATE_DDL_AUTO=NONE)
  4. application.properties 또는 application.yml
  5. 기본 설정 값(스프링 내부 기본 값)

따라서 우선순위가 높은곳에 정의된 값이 우선으로 적용됩니다.

# application.properties 파일에서의 설정
spring.jpa.hibernate.ddl-auto=create

# 환경변수에서의 설정
export SPRING_JPA_HIBERNATE_DDL_AUTO=update

# 명령어 실행
java -jar myapp.jar

환경마다 각 설정값이 다르게 설정해 실행하면, 환경변수에서의 설정이 설정파일에서의 설정보다 높은 우선순위를 가져 해당 옵션은 update로 설정됩니다.


3. 환경변수 사용하기

예시: Google SMTP를 이용해 이메일을 보내려고 합니다.

SMTP 설정법에 따라 host, port, username, password 등을 설정파일에 작성해서 설정파일로 사용하려고 합니다.

# Google SMTP 설정 (properties 파일 내부)
spring.mail.host = smtp.gmail.com
spring.mail.port = 587
spring.mail.username = 이메일 작성부분
spring.mail.password = 비밀번호 작성 부분
spring.mail.properties.mail.smtp.auth = true
spring.mail.properties.mail.smtp.starttls.enable = true
spring.mail.properties.mail.smtp.starttls.required = true
spring.mail.properties.mail.smtp.ssl.trust = smtp.gmail.com
spring.mail.properties.mail.smtp.ssl.enable = true

 

이를 EmailConfig.java라는 설정 클래스 파일에서 다음과 같이 사용할 수 있습니다.

@Configuration
public class EmailConfig {
	
	//	SMTP 이메일 전송을 위한 설정클래스
	
	@Value("${spring.mail.host}")
	private String host;
	
	@Value("${spring.mail.port}")
	private int port;
	
	@Value("${spring.mail.username}")
	private String username;
	
	@Value("${spring.mail.password}")
	private String password;
    
    ....
    
}

 

 

혹은, Environment 객체를 통해 전역 변수를 프로그램 내에서 직접 읽어오는 방법도 있습니다.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

@Component
public class EnvironmentReader {

    @Autowired
    private Environment environment;

    public String getAppName() {
        return environment.getProperty("app.name");
    }

    public String getAppVersion() {
        return environment.getProperty("app.version");
    }
}

 

 

'Java > Spring Framework' 카테고리의 다른 글

유효성 검사, Validation (2)  (7) 2025.01.03
유효성 검사, Validation (1)  (4) 2025.01.02

Garbage Collection

: 줄여서 GC 라고 부르기도 한다. 가비지 컬렉션은 메모리 관리 기법중 하나로, 동적으로 할당된 메모리 영역 중 더 이상 쓰이지 않는 영역을 자동으로 찾아내어 해제하는 기능이다.

옛날 언어들은 동적 메모리 할당 기능이 없거나, C처럼 프로그래머가 할당한 뒤 수동으로 해제까지 해 줘야 하는 방식이었는데, 사람이 하는 일이 항상 완벽할 수 없기 때문에 메모리 누수가 생기거나, 해제했던 메모리를 실수로 다시 사용하거나 해제헀던 메모리를 다시 해제하는 실수가 일어나 온갖 버그가 양산되었다. 이를 해결하기 위해 고안된 방법이 가비지 컬렉션이다.

 

가비지 컬렉션에 대해 알아보기 전에 메모리 구조 알아보기 →

 

 

 

https://namu.wiki/w/Java%20Virtual%20Machine

Java Virtual Machine(자바 가상 머신)으로 나무위키에는 Java 컴파일러가 프론트엔드를 담당한다면 Java 가상머신은 코드 최적화와 백엔드를 담당한다고 쓰여있다.

 

그렇다면 컴파일러는 무엇일까 ?

Compiler 알아보기 →


작성한 Java 소스코드는 javac 컴파일러를 거쳐 바이트코드로 변환되고, 이 바이트 코드는 JRE(Java Runtime Environment)에 들어있는 java classloader에 의해 JVM으로 적재되고 JVM에 적재된 바이트코드를 JIT(Just In Time)컴파일 방식으로 실행하는 컴퓨터의 OS 및 CPU 아키텍처용 기계어로 번역되어 수행된다.

 

JVM의 강점은 '플랫폼 독립'적으로 JVM이 실행가능한 환경이라면 어디서든 Java 프로그램이 실행될 수 있도록 하는 것이다.

"Write Once, Run Anywhere"

 

단, 특정 운영체제의 특수한 기능을 호출하거나 하드웨어를 제어하는 등의 일은 JVM으로 할 수 없으며, JNI 같은 Navite 코드를 호출하기 위한 인터페이스를 거쳐야 한다. (이 부분에 대해 찾아볼 것 !)

 

또 Java 가상머신이라고 Java 바이트 코드만 인식하는 것이 아니라, Kotlin, Scala, Groovy처럼 Java에서 파생된 언어들의 바이트 코드 또한 인식할 수 있다.

 

다만, 이 바이트 코드가 실제 기계에서 실행되는 것이 아니라, JVM의 해석단계를 거치므로 같은 기능의 네이티브 언어(C, C++, Rust, Go 등)보다 실행속도가 느리다고 한다.

(현재는 JIT 컴파일의 도입과 하드웨어 발전으로 성능이 개선되었다고 한다 !)

 

가비지 콜렉션(GC: Garbage Collection) 또한 Java 계열의 강력한 무기라고 할 수 있을까?

JVM은 가비지 컬렉션을 수행하여 할당되었다가 더 이상 쓰이지 않는 메모리를 자동으로 회수한다.

Garbage Collection 알아보기 →(미완)


Java Class 파일 단독으로 실행시켜보기

국비학원에서 수업을 들을 때, JRE, JDK를 설치하고나서 강사님께서 가장 먼저 메모장에 클래스파일을 간단하게 작성해서 javac명령어를 사용해 클래스 파일 실행을 보여주신 적이 있다. 그땐 몰랐지만 지금은 알 수 있다 😁

public class test {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

test.txt 메모장 파일을 생성해서 위와 같이 코드를 작성한다, 이후 저장하고 확장자명을 .java로 변경한다.

 

cmd 또는 powershell 명령 프롬프트를 열어, 메모장이 있는 폴더로 이동해

javac test.java 명령어를 입력한다.

그러면 같은 폴더에 test.class 파일이 생성된다.

 

다시 명령 프롬프트에 java test 명령어를 입력하면, 프롬프트에 Hello World가 출력되며 Class 파일이 실행된다.

 

이것으로 Java 소스파일의 컴파일 후 실행 과정을 간단하게 진행해 보았다.

사용자가 입력한 데이터의 검증을 위해, 스프링 프레임워크가 제공하는 Validation 기능을 이용해 봅니다.

큰 흐름은 다음과 같습니다.

 

아래 1, 2, 3번에 대한 설명이 있는 유효성 검사, Validation (1) 에 이어 4번째 항목을 소개합니다.

 

1. 기본설정 : 유저 입력 데이터를 커맨드객체로 받는다.

2. Validation 적용하기 : 커맨드객체에 Validation 어노테이션을 사용해 각 필드에 대해 유효성 검사를 실시한다.

3. 예외 처리하기 : 검사를 통과하지못해 예외발생시 해당 예외를 처리할 로직을 구현한다.

4. 추가기능 : 사용자편의를 위해 몇가지 로직을 추가 구현한다(예: 검증 그룹 등)


4. 추가기능

@Data
public class CommandVO {

    @NotBlank(message = "아이디를 입력해주세요.")
    private String username;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    @NotBlank(message = "이메일을 입력해주세요.")
    private String email;

    public List<String> getFieldOrder() {
        return List.of(
                "username",
                "password",
                "email"
        );
    }

}

현재까지 작성된 CommandVO의 내용은 위처럼 되어있습니다. @NotNull에 대한 유효성 검사만 이루어지고 있습니다.

여기에 정규표현식이나 길이제한을 넣도록 하겠습니다.

@NotNull 필드값이 null이 아니어야 함.
@Null 필드값이 null 이어야 함.
@NotEmpty 문자열, 컬렉션, 맵, 배열 등이 null이 아니고 빈 상태가 아니어야 함.
@NotBlank 문자열이 null이 아니고, 공백이 아닌 문자 하나 이상을 포함해야 함.
(””, “ “) 불가능
@Size(min=, max=) 문자열, 컬렉션, 맵, 배열 등이 주어진 범위 내에 있어야 함.
@Min(value) 숫자가 지정한 최소값 이상 이어야함.
@Max(value) 숫자가 지정한 최대값 이하 이어야 함.
@Positive 숫자가 양수여야 함.
@PositiveOrZero 숫자가 양수거나 0이어야 함.
@Negative 숫자가 음수여야 함.
@NegativeOrZero 숫자가 음수거나 0이어야 함.
@Email 문자열이 이메일 형식이어야 함.
@Digits(Integer=, fraction=) 숫자가 지정된 자릿수를 넘지 않아야 함.
(Integer = 정수부분 최대 자릿수, fraction = 소수부분 최대 자릿수)
@Pattern(regexp=) 문자열이 정규식에 매치되어야 함.

 

이 중, NotBlank와 Pattern을 함께 사용하겠습니다.

@Data
public class CommandVO {

    @NotBlank(message = "아이디는 필수정보입니다.", groups= NotBlankGroup.class)
    @Pattern(regexp = "^[a-z]+[a-z0-9]{5,20}", message = "아이디는 5~20자의 영문/숫자로만 구성해주세요.", groups=PatternGroup.class)
    private String username;

    @NotBlank(message = "비밀번호는 필수정보입니다.", groups=NotBlankGroup.class)
    @Pattern(regexp = "^(?=.*\\S+$)(?=.*[\\W_]).{8,16}$", message = "비밀번호는 8~16자, 특수문자를 반드시 포함해야 합니다.", groups=PatternGroup.class)
    private String password;

    @NotBlank(message = "이메일은 필수정보입니다.", groups=NotBlankGroup.class)
    @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "올바른 이메일 형식이 아닙니다.", groups=PatternGroup.class)
    private String email;

    public List<String> getFieldOrder() {
        return List.of(
                "username",
                "password",
                "email"
        );
    }

}

현재의 코드는 각 필드마다

1. 사용자가 아무것도 입력을 하지 않았을 때

2. 정규표현식에 어긋날 때 

두 가지 유효성 검사를 실시하고, 해당 검사와 예외에 따른 메시지를 포함하고 있습니다.

 

어노테이션 속성에 groups이 포함되었는데 이 group이 이전 1편에서 언급한 '검증 그룹'입니다.

검증그룹은 별도의 클래스안에 인터페이스로서 명시할 수 있습니다.

public class ValidationGroups {
    public interface NotBlankGroup{}
    public interface SizeGroup{}
    public interface PatternGroup{}
    public interface AssertTrueGroup{}
}

ValidationGroups.class 파일 안에 내가 그룹명을 사용할 이름을 인터페이스로 명시합니다.

 

그리고 CommandVO의 Valid 어노테이션에 groups 속성으로 설정해 두면, 해당 어노테이션이 내가 설정한 groups에 속하게 됩니다.

 

이렇게 그룹설정을 하는 이유는 1편에서 설명한 바와 같이 컨트롤러 메서드에따라 검증그룹을 선택할 수 있을 뿐만 아니라, 검증 순서도 정할 수 있기 때문입니다.

지금상태에서는 사용자 데이터 유효성검사를 실행하면 아이디 → 닉네임 → 이메일 검증 순서는 작동되지만,

아이디 순서일때 빈칸에 대한 유효성 검사가 진행되거나 정규표현식 유효성 검사가 진행되거나 랜덤이므로 이 순서도 정해주어야 합니다.

@GroupSequence({
        ValidationGroups.NotBlankGroup.class,
        ValidationGroups.SizeGroup.class,
        ValidationGroups.PatternGroup.class,
})
public interface ValidationOrder {
}

ValidationOrder 인터페이스를 만들고, @GroupSequence 어노테이션에 검증을 실시할 그룹을 순서대로 넣어주고, 컨트롤러의 @Validated 어노테이션에 이 순서를 사용함을 명시해주면 됩니다.

 

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(@Validated(ValidationOrder.class) @ModelAttribute CommandVO commandVO)

 

이렇게 적용하면 CommandVO의 유효성검사는 username(1. NotBlank, 2. Pattern) → password(1. Notblank, 2. Pattern) → email(1. NotBlank, 2. Pattern) 순서대로 이루어져 사용자에게 예외 발생시 안내메시지를 응답합니다.

'Java > Spring Framework' 카테고리의 다른 글

설정파일을 이용한 환경 변수 설정  (3) 2025.01.13
유효성 검사, Validation (1)  (4) 2025.01.02

https://github.com/JH-Keem/Spring-Web-Utils/tree/main/Spring_Validation

 

사용자가 입력한 데이터의 검증을 위해, 스프링 프레임워크가 제공하는 Validation 기능을 이용해 봅니다.

큰 흐름은 다음과 같습니다.

 

1. 기본설정 : 유저 입력 데이터를 커맨드객체로 받는다.

2. Validation 적용하기 : 커맨드객체에 Validation 어노테이션을 사용해 각 필드에 대해 유효성 검사를 실시한다.

3. 예외 처리하기 : 검사를 통과하지못해 예외발생시 해당 예외를 처리할 로직을 구현한다.

4. 추가기능 : 사용자편의를 위해 몇가지 로직을 추가 구현한다(예: 검증 그룹 등)


1. 기본 설정

아주 간단하게 JSON 응답을 하는 컨트롤러를 먼저 만들어 보겠습니다.

@RestController
@RequestMapping("/")
public class DefaultController {

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(CommandVO commandVO){
        Map<String, Object> response = new HashMap<>();

	//	사용자가 입력한 데이터를 출력해봅니다.
        System.out.println(commandVO.toString());

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

}

우선, 회원가입이라고 가정하고 Post HTTP 메서드를 이용해 요청을 받기로 하겠습니다.

사용자가 입력하는 데이터를 CommandVO 객체로 매핑해 파라미터 주입받는 모습입니다.

CommandVO 객체는 아래와 같이 만들었습니다.

 

@Data
public class CommandVO {

    private String username; 	//	사용자 아이디
    private String password; 	//	사용자 비밀번호
    private String email;	//	사용자 이메일
    
}

사용자는 파라미터로 username, password, email을 입력한다고 가정해보겠습니다.

 

사용자가 각 필드를 입력해 POST 요청을 보내면 아래와 같이 콘솔창에 사용자 입력 데이터가 잘 출력되는 모습을 볼 수 있습니다.


1. Validation 적용하기

Spring Validation을 객체에 적용하기 위해서는 파라미터 주입받는 객체 앞에 @Valid 혹은 @Validated 어노테이션을 사용하면 됩니다. 두 어노테이션의 차이는 다음과 같습니다.

특징 @Valid @Validated
제공 패키지 javax.validation org.springframework.validation
검증 대상 객체의 필드 수준 검증 클래스/메서드 수준 검증
검증 그룹 지원 지원 X 지원(Validation Groups)
중첩 객체 검증 지원(@Valid로 지원된 필드) 중첩 객체 검증 불가
사용 위치 주로 컨트롤러 계층 서비스 계층 및 메서드 매개변수

 

이 게시글에서는, 더 다양한 검증 그룹의 사용을 위해 @Validated 어노테이션을 사용합니다.

검증 그룹 지원이란 개발자가 커스터마이징해 유효성 검증할 그룹들을 나누어서 필요한 때에 필요한 그룹에 대한 검증만 가능토록 하는것을 지원한다는 이야기입니다.

더보기

예) 게시글 객체가 '글 제목, 작성자, 글 내용' 세 필드로 작성되었다고 가정해봅니다.

글 작성시에는 글 제목, 작성자, 글 내용이 필요하므로 세 필드에 대해 'Write.class'라고 임의로 이름지어놓고,

글 수정시에는 글 제목과 내용에만 추가로 'Edit.class'라고 임의로 이름 짓습니다.

 

나중에 글 작성 컨트롤러의 파라미터 에서는 게시글 객체에 'Write.class'그룹에 대한 검증만 실행되도록 하고, 글 수정 컨트롤러의 파라미터에서는 'Edit.class'그룹에 대한 검증만 실행하도록 할 수 있습니다.

우선 컨트롤러 메서드에 @Validated 어노테이션을 추가하고, CommandVO 객체에 간단한 유효성검증 어노테이션을 추가하겠습니다.

//	Controller 메서드에 @Validated 어노테이션 추가
@RestController
@RequestMapping("/")
public class DefaultController {

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(@Validated CommandVO commandVO){
        Map<String, Object> response = new HashMap<>();

        System.out.println(commandVO.toString());

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

}

//	CommandVO 각 필드에 @NotNull 어노테이션 추가
@Data
public class CommandVO {

    @NotBlank(message = "아이디를 입력해주세요.")
    private String username;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    @NotBlank(message = "이메일을 입력해주세요.")
    private String email;

}

 

이후 같은 방식으로 Postman을 이용해 아무것도 입력하지 않은 데이터로 요청을 보내면

콘솔창에 MethodArgumentNotValidException 예외가 발생했다고 출력됩니다.

 

이제 이 예외를 처리해서 사용자에게 메시지를 응답해보도록 하겠습니다.

 

유효성 검사를 실시할 필드에 사용할 수 있는 어노테이션은 다음과 같은 것들이 있습니다.

@NotNull 필드값이 null이 아니어야 함.
@Null 필드값이 null 이어야 함.
@NotEmpty 문자열, 컬렉션, 맵, 배열 등이 null이 아니고 빈 상태가 아니어야 함.
@NotBlank 문자열이 null이 아니고, 공백이 아닌 문자 하나 이상을 포함해야 함.
(””, “ “) 불가능
@Size(min=, max=) 문자열, 컬렉션, 맵, 배열 등이 주어진 범위 내에 있어야 함.
@Min(value) 숫자가 지정한 최소값 이상 이어야함.
@Max(value) 숫자가 지정한 최대값 이하 이어야 함.
@Positive 숫자가 양수여야 함.
@PositiveOrZero 숫자가 양수거나 0이어야 함.
@Negative 숫자가 음수여야 함.
@NegativeOrZero 숫자가 음수거나 0이어야 함.
@Email 문자열이 이메일 형식이어야 함.
@Digits(Integer=, fraction=) 숫자가 지정된 자릿수를 넘지 않아야 함.
(Integer = 정수부분 최대 자릿수, fraction = 소수부분 최대 자릿수)
@Pattern(regexp=) 문자열이 정규식에 매치되어야 함.

2. 예외 처리하기

발생한 유효성검증 예외를 처리하는 방법은 여러가지가 있지만, 대표적으로 2가지가 있습니다.

전역 예외 처리 핸들러를 사용하거나, Error 객체를 사용해 처리하는 방식입니다.

 

먼저 전역 예외 처리 핸들러를 사용하는 방법을 알아보겠습니다.

간단하게 ExceptionHandler.java 클래스를 다음과 같이 구현합니다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({MethodArgumentNotValidException.class})
    public ResponseEntity<?> handleValidationException(Exception ex){
    	// 사용자에게 응답할 객체
        Map<String, Object> response = new HashMap<>();
        
        // 예외를 처리할 BindingResult객체(아래 설명 참조)
        BindingResult bindingResult = null;
        if(ex instanceof MethodArgumentNotValidException){
             bindingResult = ((MethodArgumentNotValidException)ex).getBindingResult();
        }

        response.put("message", bindingResult.getFieldError());
        return ResponseEntity.ok().body(response);
    }
}

@ControllerAdvice 어노테이션은 모든 @Controller 어노테이션이 할당된 클래스에서 발생하는 예외를 처리해주는 어노테이션 입니다. 비슷한 어노테이션으로는 @RestControllerAdvice 등이 있습니다.

 

메서드에 작성된 @ExceptionHandler({예외클래스}) 메서드는 파라미터에 작성된 예외를 처리하겠음을 명시합니다.

앞서 유효성검사 과정에서 발생된 예외가 MethodArgumentNotValidException 예외였으므로 이 예외를 작성해줍니다.

 

메서드의 파라미터에서는 해당 예외를 처리할 수 있도록 Exception 객체를 주입받습니다.

 

우리가 받는 사용자 데이터는 Spring의 데이터 바인딩 과정을 통해 사용자 데이터 -> CommandVO 객체로 변환됩니다.

예외는 이 과정에서 발생하므로 데이터 바인딩중 발생한 에러를 다루기 위해 BindingResult 객체가 필요한데, BidningResult 객체는 Exception객체 하위의 상세 예외객체에서 가져올 수 있으므로, 이 과정을 위해 예외가 정확히 어떤 예외인지 형변환을 실시해야 합니다.

현재 이 예시에서는 MethodArgumentNotValidException을 다루므로, 해당 예외로 형변환 합니다.

 

발생한 필드에러를 곧장 응답하게되면 아래와 같은 응답을 받아볼 수 있습니다.

{
    "message": {
        "objectName": "commandVO",
        "field": "email",
        "rejectedValue": null,
        "codes": [
            "NotBlank.commandVO.email",
            "NotBlank.email",
            "NotBlank"
        ],
        "arguments": [
            {
                "codes": [
                    "commandVO.email",
                    "email"
                ],
                "arguments": null,
                "defaultMessage": "email",
                "code": "email"
            }
        ],
        "defaultMessage": "이메일을 입력해주세요.",
        "bindingFailure": false,
        "code": "NotNull"
    }
}

보통, 유효성검사를 진행하고 이로인한 안내메시지를 필요로 하는 경우에, 사용자경험을 위해 아이디 → 비밀번호 → 이메일 순으로 메시지를 보내고 싶은데 현재의 예시처럼 진행하면 세 에러메시지가 랜덤으로 응답되게 됩니다.

 

이를 해결하는 방법으로는 CommandVO 내부에 원하는 순서대로 정렬한 필드 이름의 배열을 반환하는 메서드를 만들고, 해당 배열의 필드 이름 순서와 error객체의 getField() 메서드를 통해 반환받은 순서를 비교해 정렬하면 됩니다.

@Data
public class CommandVO {

    @NotBlank(message = "아이디를 입력해주세요.")
    private String username;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    @NotBlank(message = "이메일을 입력해주세요.")
    private String email;

    public List<String> getFieldOrder() {
        return List.of(
                "username",
                "password",
                "email"
        );
    }
}

CommandVO 내부에 필드 이름을 순서대로 가지고있는 배열을 반환하는 메서드를 만들었습니다.

 

 

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({MethodArgumentNotValidException.class})
    public ResponseEntity<?> handleValidationException(Exception ex){
        Map<String, Object> response = new HashMap<>();

        BindingResult bindingResult = null;
        if(ex instanceof MethodArgumentNotValidException){
             bindingResult = ((MethodArgumentNotValidException)ex).getBindingResult();
        }

        Object target = bindingResult.getTarget();
        List<String> fieldOrder = List.of();

        List<FieldError> fieldErrors = new ArrayList<>(bindingResult.getFieldErrors());

        final List<String> finalFieldOrder = fieldOrder;
        fieldErrors.sort(Comparator.comparingInt(error -> {
            int index = finalFieldOrder.indexOf(error.getField());
            return index != -1 ? index : Integer.MAX_VALUE;
        }));

        FieldError firstError = fieldErrors.stream().findFirst().orElse(null);

        if (firstError != null) {
            return ResponseEntity.ok().body(Map.of("message", firstError.getDefaultMessage()));
        }

        return ResponseEntity.ok().body(Map.of("message", "요청이 유효하지 않습니다."));
    }
}

CommandVO의 필드순서 리스트를 가져와 람다식을 이용해 순서를 정렬합니다.

1. fieldError 리스트에 있는 원소 error 객체에서 getField() 메서드를 통해 필드명을 반환받습니다.

2. 각 필드명이 CommandVO에서 작성한 필드순서의 몇 번째 위치에 있는지 확인합니다.

3. 해당 필드명이 CommandVO의 필드순서 리스트에 있다면 해당 위치의 인덱스값을 반환합니다.

4. 없다면 Integer.MAX_VALUE를 반환해 최하위로 정렬합니다.

 

정렬된 fieldErrors 리스트에서 첫번째 error를 가져와 해당 메시지를 응답합니다.

 

이렇게 만들면 아이디 → 비밀번호 → 이메일 순으로 사용자에게 안내메시지를 전달할 수 있습니다.

 

순서대로 처리해야 하는 상황이 아니라 에러메시지를 모아 한번에 전달하려면 어떻게 할까요 ?

전역 예외 처리 핸들러가 아니라 Error 객체를 이용해 처리해보도록 하겠습니다.

 

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(@Validated CommandVO commandVO, Errors errors){
        Map<String, Object> response = new HashMap<>();

        System.out.println(commandVO.toString());
        System.out.println(errors.getFieldErrors().toString());

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

컨트롤러의 파라미터로 Errors 객체를 주입받는 것으로 '이 메서드에서 발생한 예외를 직접 처리하겠음'을 명시하게 됩니다.

 

errors.getFieldErrors().toString() 메서드로 예외를 출력해보면, 전역 예외 처리 핸들러를 사용한것과 같은 에러문구가 출력됩니다.

 

Errors 객체에는 어떤 필드에서 오류가 났는지 어떤 메시지를 예외로 출력하는지 모두 가지고 있으므로 다음과 같이 처리할 수 있습니다.

@RestController
@RequestMapping("/")
public class DefaultController {

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(@Validated CommandVO commandVO, Errors errors){
        Map<String, Object> response = new HashMap<>();

        System.out.println(commandVO.toString());
        System.out.println(errors.getFieldErrors().toString());

	// 검증결과를 담을 validatorResult 해쉬맵 객체 생성
        Map<String, String> validatorResult = new HashMap<>();
        
        // 에러가 난 필드를 Key로, 에러 메시지를 Value로 삽입
        for(FieldError error : errors.getFieldErrors()) {
            String validKeyName = String.format("%sErrorMsg", error.getField());
            validatorResult.put(validKeyName, error.getDefaultMessage());
        }
        
        // 이후 개발자의 의도에 맞게 처리

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

}

validatorResult를 응답에 포함시켜 프론트에서 적절히 처리하도록 합시다.

 

유효성 검사, Validation (2) 편에서는 CommandVO에 NotNull 외에 다른 어노테이션을 여러개 동시에 붙이면서, 해당 어노테이션들의 검증 순서를 관리하는 방법에 대해 작성하겠습니다.

'Java > Spring Framework' 카테고리의 다른 글

설정파일을 이용한 환경 변수 설정  (3) 2025.01.13
유효성 검사, Validation (2)  (7) 2025.01.03

+ Recent posts