5/26 - Spring MVC → Spring Boot, Test
🚀 Spring Boot로의 전환: Mapper, DTO, Service, Controller, 그리고 테스트
기존 Spring MVC 프로젝트를 Spring Boot로 전환하는 과정에서 필요한 주요 설정, 코드 구조, 그리고 테스트 방법에 대해 상세히 정리했습니다.
📦 의존성 추가 (build.gradle)
Spring Boot 프로젝트에서 MySQL 데이터베이스와 MyBatis를 사용하려면 build.gradle 파일에 다음과 같은 의존성을 추가해야 합니다.
mapper(@Mapper 어노테이션 추가), dto, util, resources/mappers 파일은 동일하게 가져오자
build.gradle
mysqldriver, mybatisframework 추가
application.properties
dependencies {
// ... 기존 의존성 ...
implementation 'mysql:mysql-connector-java:8.0.28' // MySQL Driver (버전은 환경에 맞게)
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.0' // MyBatis Spring Boot Starter (버전은 환경에 맞게)
// Spring Boot Test를 위한 의존성은 starter에 포함되어 있을 수 있지만, 명시적으로 추가하는 것도 좋습니다.
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// AssertJ는 Spring Boot Test에 기본 포함되어 있습니다.
}
⚙️ Spring Boot 설정 (application.properties)
Spring Boot는 application.properties 또는 application.yml 파일을 통해 데이터베이스 연결, MyBatis 설정 등을 간편하게 관리합니다.
spring.application.name=02_mvc-to-boot-migration
# DataSource 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/springdb?serverTimezone=Asia/Seoul
spring.datasource.username=ino
spring.datasource.password=${spring.datasource.username} # 환경 변수 또는 직접 입력
# MyBatis 설정
mybatis.mapper-locations=classpath:mappers/*.xml # XML 매퍼 파일 위치 (classpath: 접두사 필수)
mybatis.type-aliases-package=com.ino.app.dto # DTO 클래스가 위치한 패키지
설명:
- spring.datasource.*: 데이터베이스 연결 정보를 설정합니다. 드라이버 클래스, JDBC URL, 사용자 이름, 비밀번호 등을 지정합니다. serverTimezone=Asia/Seoul을 추가하여 시간대 불일치 문제를 방지하는 것이 좋습니다.
- mybatis.mapper-locations: MyBatis XML 매퍼 파일의 위치를 지정합니다. classpath: 접두사를 사용하여 클래스패스 내의 경로임을 명시합니다. (resources 폴더 아래 mappers 폴더에 XML 파일이 있다면 mappers/*.xml로 설정)
- mybatis.type-aliases-package: MyBatis가 DTO 클래스를 인식할 수 있도록 타입 별칭 패키지를 지정합니다. 이 패키지 내의 모든 클래스는 별도의 @Alias 어노테이션 없이 클래스 이름으로 별칭이 자동 등록됩니다.
🔄 기존 XML 설정과 비교 (Spring MVC -> Spring Boot)
Spring Boot는 자동 설정을 통해 기존 Spring MVC의 복잡한 XML 빈 설정을 대폭 간소화합니다.
변경 이전 (Spring MVC XML 설정):
<bean class="com.zaxxer.hikari.HikariDataSource" id="hikariDataSource">
<property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
<property name="jdbcUrl" value="jdbc:mysql://localhost:3306/springdb"/>
<property name="username" value="kangbroo"/>
<property name="password" value="kangbroo"/>
</bean>
<bean class="org.mybatis.spring.SqlSessionFactoryBean" id="sqlSessionFactory">
<property name="dataSource" ref="hikariDataSource" />
<property name="configLocation" value="classpath:config/mybatis-config.xml" />
<property name="mapperLocations" value="classpath:mappers/*.xml" />
</bean>
<bean class="org.mybatis.spring.SqlSessionTemplate" id="sqlSession">
<constructor-arg ref="sqlSessionFactory" />
</bean>
<bean class="org.springframework.jdbc.datasource.DataSourceTransactionManager" id="txManager">
<property name="dataSource" ref="hikariDataSource"/>
</bean>
Spring Boot에서의 변화:
위의 모든 설정 (DataSource, SqlSessionFactory, SqlSessionTemplate, TransactionManager)은 spring-boot-starter-jdbc와 mybatis-spring-boot-starter 의존성만 추가하면 Spring Boot가 자동으로 설정해 줍니다. 개발자는 application.properties에 간단한 정보만 입력하면 됩니다.
📝 Mapper 인터페이스 및 XML 파일
Mapper 인터페이스는 @Mapper 어노테이션을 추가하여 Spring에게 매퍼 빈으로 인식하도록 합니다. XML 매퍼 파일과 인터페이스 메소드 이름이 일치해야 합니다.
뷰단 생성 이전, 테스트를 위해 Mapper에 대해 테스트 코드를 작성해주자.
// com.ino.app.mapper.UserMapper.java
package com.ino.app.mapper;
import com.ino.app.dto.UserDto;
import org.apache.ibatis.annotations.Mapper;
@Mapper // 이 인터페이스가 MyBatis 매퍼임을 Spring에 알림
public interface UserMapper {
int insertUser(UserDto user);
int selectUserCountById(String checkId);
UserDto selectUserById(String userId);
int updateProfileImg(UserDto user); // 프로필 이미지 URL 업데이트 메소드 추가
// ... 필요한 다른 CRUD 메소드 ...
}
http://mybatis.org/dtd/mybatis-3-mapper.dtd>">
INSERT INTO user_tbl (user_id, user_pwd, user_name, email, gender, address, phone)
VALUES (#{userId}, #{userPwd}, #{userName}, #{email}, #{gender}, #{address}, #{phone})
SELECT COUNT(*) FROM user_tbl WHERE user_id = #{checkId}
SELECT user_id, user_pwd, user_name, email, gender, address, phone, profile_url
FROM user_tbl
WHERE user_id = #{userId}
UPDATE user_tbl SET profile_url = #{profileURL} WHERE user_id = #{userId}
🧪 Mapper 테스트 (SpringBootTest)
@SpringBootTest 어노테이션을 사용하면 실제 Spring Boot 애플리케이션이 구동되는 것과 유사한 환경에서 테스트를 수행할 수 있습니다. 이는 애플리케이션 컨텍스트를 전체 로딩하고 모든 빈을 등록하므로, Mapper 인터페이스의 빈 등록 여부와 실제 데이터베이스 연동까지 검증할 수 있습니다.
package com.ino.app; // 적절한 패키지명으로 변경
import com.ino.app.dto.UserDto;
import com.ino.app.mapper.UserMapper;
import org.assertj.core.api.Assertions; // AssertJ 사용
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional; // 트랜잭션 롤백을 위한 어노테이션
@SpringBootTest
class UserMapperTest {
@Autowired
private UserMapper userMapper;
// 테스트 포맷: Given-When-Then
// Given (테스트 데이터 준비) - When (테스트 실행) - Then (실행 결과와 예측값 비교를 통한 검증)
@Test
@Transactional // 테스트 후 데이터베이스 변경사항 롤백
void insertUser() {
// Given
UserDto user = UserDto.builder()
.userId("junitTest")
.userPwd("junitTestPwd")
.userName("테스트유저")
.email("junit@example.com")
.gender("M")
.address("서울시")
.phone("010-1111-1111")
.build();
// When
int result = userMapper.insertUser(user);
// Then
Assertions.assertThat(result).isEqualTo(1); // 1개의 행이 삽입되었는지 확인
// 선택: 삽입된 데이터를 다시 조회하여 확인
UserDto insertedUser = userMapper.selectUserById("junitTest");
Assertions.assertThat(insertedUser).isNotNull();
Assertions.assertThat(insertedUser.getUserName()).isEqualTo("테스트유저");
}
@Test
void selectUserCountById() {
// Given
String checkId = "admin01"; // 실제 DB에 존재하는 ID로 가정
// When
int count = userMapper.selectUserCountById(checkId);
// Then
Assertions.assertThat(count).isEqualTo(1); // 해당 ID의 사용자 수가 1인지 확인
}
@Test
void selectUserById() {
// Given
String userId = "user01"; // 실제 DB에 존재하는 ID로 가정
// When
UserDto user = userMapper.selectUserById(userId);
// Then
Assertions.assertThat(user).isNotNull(); // UserDto 객체가 null이 아닌지 확인
Assertions.assertThat(user.getUserName()).isEqualTo("홍길동"); // 이름이 일치하는지 확인
}
@Test
@Transactional
void updateProfileImg() {
// Given
UserDto user = userMapper.selectUserById("user01"); // 기존 사용자 조회
Assertions.assertThat(user).isNotNull();
String newProfileUrl = "/upload/new_profile.png";
user.setProfileURL(newProfileUrl);
// When
int result = userMapper.updateProfileImg(user);
// Then
Assertions.assertThat(result).isEqualTo(1);
UserDto updatedUser = userMapper.selectUserById("user01");
Assertions.assertThat(updatedUser.getProfileURL()).isEqualTo(newProfileUrl);
}
}
- @SpringBootTest: Spring Boot 테스트를 위한 핵심 어노테이션입니다.
- @Autowired: 테스트 대상 Mapper를 주입받습니다.
- @Transactional: 테스트 메소드 실행 후 데이터베이스 변경사항을 자동으로 롤백하여 테스트 간의 독립성을 보장합니다. 삽입, 수정, 삭제 테스트 시 반드시 사용하는 것이 좋습니다.
- org.assertj.core.api.Assertions: 보다 가독성 높은 테스트 코드 작성을 위해 AssertJ 라이브러리의 assertThat을 사용합니다. Spring Boot Test는 기본적으로 AssertJ를 포함합니다.
🛡️ 서비스 레이어 재구현 (UserService)
서비스 레이어는 비즈니스 로직을 담당하며, 여러 Mapper 호출을 조합하거나 외부 API 연동 등을 수행합니다.
package com.ino.app.service;
import com.ino.app.dto.UserDto;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
public interface UserService {
// 회원 아이디 수 조회 (중복 체크)
int getUserCount(String checkId);
// 회원 가입
Map<String, Object> registUser(UserDto user);
// 로그인
Map<String, Object> loginUser(UserDto user);
// 프로필 이미지 변경
Map<String, Object> modifyUserProfile(String userId, MultipartFile file);
// (추가) ID로 사용자 정보 가져오기 (세션 업데이트용)
UserDto getUserById(String userId);
}
package com.ino.app.service.impl;
import com.ino.app.dto.UserDto;
import com.ino.app.mapper.UserMapper;
import com.ino.app.service.UserService;
import com.ino.app.util.FileUtil; // 파일 업로드 유틸리티 (가정)
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; // 트랜잭션 처리를 위한 어노테이션
import org.springframework.web.multipart.MultipartFile;
import java.io.File; // java.io.File 임포트
import java.util.HashMap; // Map.of는 Java 9+
import java.util.Map;
@Service // 이 클래스가 서비스 계층의 빈임을 Spring에 알림
@RequiredArgsConstructor // Lombok을 사용하여 final 필드에 대한 생성자 자동 생성 (DI 용이)
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final FileUtil fileUtil; // 파일 업로드 유틸리티 의존성 추가
@Override
public int getUserCount(String checkId) {
return userMapper.selectUserCountById(checkId);
}
@Override
@Transactional // 회원가입 트랜잭션 처리
public Map<String, Object> registUser(UserDto user) {
Map<String, Object> response = new HashMap<>(); // Java 8 호환을 위해 HashMap 사용
try {
// 여기에 추가적인 비즈니스 로직 (예: 비밀번호 암호화)
int result = userMapper.insertUser(user);
if (result > 0) {
response.put("message", "Success: 회원가입이 완료되었습니다.");
} else {
response.put("message", "Error: 회원가입에 실패했습니다.");
}
} catch (Exception e) {
e.printStackTrace();
response.put("message", "Error: 서버 오류로 회원가입에 실패했습니다.");
// 트랜잭션이 선언되어 있어 RuntimeException 발생 시 롤백됨
throw new RuntimeException("회원가입 중 오류 발생", e);
}
return response;
}
@Override
public Map<String, Object> loginUser(UserDto user) {
Map<String, Object> response = new HashMap<>();
UserDto selectedUser = userMapper.selectUserById(user.getUserId());
if (selectedUser == null) {
response.put("message", "Error: 존재하지 않는 아이디입니다.");
} else if (!selectedUser.getUserPwd().equals(user.getUserPwd())) {
// 실제 서비스에서는 비밀번호 암호화 및 비교 로직 필요
response.put("message", "Error: 비밀번호가 일치하지 않습니다.");
} else {
response.put("message", selectedUser.getUserName() + "님 환영합니다!");
response.put("user", selectedUser);
}
return response;
}
@Override
@Transactional // 프로필 이미지 변경 트랜잭션 처리
public Map<String, Object> modifyUserProfile(String userId, MultipartFile file) {
Map<String, Object> response = new HashMap<>();
// 1. 아이디를 통해 기존 회원 정보 조회
UserDto selectedUser = userMapper.selectUserById(userId);
if (selectedUser == null) {
response.put("message", "Error: 사용자를 찾을 수 없습니다.");
return response;
}
// 2. 첨부파일 업로드 및 경로 설정
String newProfileURL = null;
String fullFilePath = null; // 파일을 저장한 실제 경로
try {
// FileUtil.fileupload는 Map<String, String>을 반환하고
// filePath와 filesystemName 키를 포함한다고 가정
Map<String, String> fileInfo = fileUtil.fileupload("profile", file);
if (fileInfo != null && fileInfo.containsKey("filePath") && fileInfo.containsKey("filesystemName")) {
newProfileURL = fileInfo.get("filePath") + "/" + fileInfo.get("filesystemName");
fullFilePath = fileInfo.get("fullPath"); // FileUtil이 실제 저장된 파일의 전체 경로를 반환한다고 가정
} else {
response.put("message", "Error: 프로필 이미지 업로드 실패 - 파일 정보가 유효하지 않습니다.");
return response;
}
} catch (Exception e) {
e.printStackTrace();
response.put("message", "Error: 프로필 이미지 업로드 중 오류가 발생했습니다.");
throw new RuntimeException("프로필 이미지 업로드 중 오류 발생", e); // 트랜잭션 롤백을 위해 RuntimeException 발생
}
// 3. 사용자 정보 DB 업데이트
selectedUser.setProfileURL(newProfileURL); // DTO에 새로운 프로필 URL 설정
int result = userMapper.updateProfileImg(selectedUser);
if (result != 1) { // 쿼리 실패 시
// DB 기록 실패 시 저장된 파일 삭제 (롤백과 별개로 물리 파일 삭제)
if (fullFilePath != null) {
new File(fullFilePath).delete();
}
response.put("message", "Error: 사용자 데이터 업데이트 실패.");
throw new RuntimeException("사용자 데이터 업데이트 실패"); // 트랜잭션 롤백
}
response.put("message", "Success: 프로필 이미지가 성공적으로 변경되었습니다.");
response.put("user", selectedUser); // 업데이트된 사용자 정보 반환
return response;
}
@Override
public UserDto getUserById(String userId) {
return userMapper.selectUserById(userId);
}
}
- @Service: 해당 클래스가 서비스 계층의 빈임을 Spring에 알립니다.
- @RequiredArgsConstructor: Lombok 어노테이션으로, final로 선언된 필드들을 초기화하는 생성자를 자동으로 생성해 주어 의존성 주입(DI) 코드를 줄여줍니다.
- @Transactional: 메소드 실행 중 예외 발생 시 트랜잭션을 롤백하여 데이터 일관성을 유지합니다. 데이터 변경(Insert, Update, Delete)이 일어나는 메소드에는 반드시 적용하는 것이 좋습니다.
- FileUtil: 파일 업로드 로직을 처리하는 유틸리티 클래스가 별도로 존재한다고 가정했습니다. 실제 구현 시 해당 클래스의 상세 로직이 필요합니다. FileUtil.fileupload는 저장된 파일의 웹 접근 가능한 URL (filePath/filesystemName)과 실제 서버 경로 (fullPath)를 반환하도록 설계하는 것이 유용합니다.
- Map.of() 대신 new HashMap<>(): Map.of()는 Java 9부터 지원되므로, Java 8 환경에서는 new HashMap<>()을 사용하여 맵을 생성해야 합니다.
- 예외 처리 및 롤백: try-catch 블록을 통해 예외를 처리하고, 특히 modifyUserProfile 메소드에서는 DB 업데이트 실패 시 업로드된 파일을 삭제하는 로직과 함께 RuntimeException을 발생시켜 @Transactional에 의해 트랜잭션이 롤백되도록 유도합니다.
🌐 컨트롤러 레이어 (UserController)
컨트롤러는 HTTP 요청을 받아 서비스 레이어의 비즈니스 로직을 호출하고, 그 결과를 클라이언트에게 응답합니다.
회원 서비스
- 회원가입페이지 요청 : GET /user/signup.page
- 아이디 중복 체크 : GET /user/idcheck.do - param(checkId=입력값)
- 회원가입요청 : POST /user/signup.do - param(form 입력값)
로그인/로그아웃 관련
- 로그인 요청 : POST /user/signin.do - param(userId=입력값,userPwd=입력값)
- 로그아웃 요청 : GET /user/signout.do
마이페이지 관련
- 마이페이지 요청 : GET /user/myinfo.page
- 프로필 변경 요청 : POST /user/modifyProfile.do - param(uploadFile=첨부파일)
- 정보 수정 요청 : POST /user/modify.do - param(폼 입력값)
- 회원 탈퇴 요청 : POST /user/resign.do - param(userPwd=검증용비번)
package com.ino.app.controller;
import com.ino.app.dto.UserDto;
import com.ino.app.service.UserService;
import jakarta.servlet.http.HttpSession; // Servlet API 사용 (jakarta.servlet로 변경)
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.Map;
@RequestMapping("/user") // 모든 메소드에 공통적으로 적용될 URL prefix
@Controller // 이 클래스가 컨트롤러임을 Spring에 알림
@RequiredArgsConstructor // Lombok을 사용하여 UserService 주입
public class UserController {
private final UserService userService;
// 회원가입 페이지 요청
@GetMapping("/signup.page")
public String signupPage() {
return "user/signup"; // src/main/resources/templates/user/signup.html (또는 .jsp)
}
// 마이페이지 요청
@GetMapping("/myinfo.page")
public String myinfoPage() {
return "user/myinfo"; // src/main/resources/templates/user/myinfo.html (또는 .jsp)
}
// 아이디 중복 체크 (AJAX 요청)
@ResponseBody // 반환되는 문자열을 HTTP 응답 본문에 직접 작성
@GetMapping("/idcheck.do")
public String idcheck(@RequestParam("checkId") String checkId) { // @RequestParam 명시
return userService.getUserCount(checkId) == 0 ? "NOTUSED" : "USED";
}
// 회원가입 요청
@PostMapping("/signup.do")
public String signup(UserDto user, RedirectAttributes redirectAttributes) {
Map<String, Object> response = userService.registUser(user);
redirectAttributes.addFlashAttribute("message", response.get("message"));
return "redirect:/"; // 루트 경로로 리다이렉트
/*
Network 탭 확인:
- /signup.do 응답 코드: 302 (Redirect)
- localhost 응답 코드: 200 (리다이렉트 후 최종 페이지)
*/
}
// 로그인 요청
@PostMapping("/signin.do")
public String signin(UserDto user, RedirectAttributes redirectAttributes, HttpSession session) {
Map<String, Object> response = userService.loginUser(user);
redirectAttributes.addFlashAttribute("message", response.get("message"));
UserDto loggedInUser = (UserDto) response.get("user");
if (loggedInUser != null) {
session.setAttribute("loginUser", loggedInUser); // 로그인 성공 시 세션에 사용자 정보 저장
}
return "redirect:/";
}
// 로그아웃 요청
@GetMapping("/signout.do")
public String signout(HttpSession session) {
session.invalidate(); // 세션 무효화
return "redirect:/";
}
// 프로필 이미지 변경 요청
@ResponseBody // 비동기(AJAX) 요청에 대한 응답으로 문자열 반환
@PostMapping("/modifyProfile.do")
public String modifyProfile(@RequestParam("uploadFile") MultipartFile uploadFile, HttpSession session) {
UserDto loginUser = (UserDto) session.getAttribute("loginUser");
if (loginUser == null) {
return "FAIL:NOT_LOGGED_IN"; // 로그인되지 않은 사용자
}
Map<String, Object> response = userService.modifyUserProfile(loginUser.getUserId(), uploadFile);
if ("Success: 프로필 이미지가 성공적으로 변경되었습니다.".equals(response.get("message"))) {
// 세션의 loginUser 정보 업데이트 (서비스에서 업데이트된 user 객체를 반환했다고 가정)
session.setAttribute("loginUser", response.get("user"));
return "SUCCESS";
} else {
return "FAIL:" + response.get("message");
}
}
// 정보 수정 요청 (구현 필요)
@PostMapping("/modify.do")
public String modify(UserDto user, RedirectAttributes redirectAttributes, HttpSession session) {
// TODO: 사용자 정보 수정 로직 구현
// 1. 세션에서 현재 로그인 사용자 정보 가져오기
// 2. 받은 user DTO를 사용하여 정보 업데이트 (비밀번호, 이름, 이메일 등)
// 3. 서비스 호출하여 DB 업데이트
// 4. 성공 시 세션 업데이트 및 성공 메시지, 실패 시 실패 메시지
redirectAttributes.addFlashAttribute("message", "정보 수정 기능은 아직 구현되지 않았습니다.");
return "redirect:/user/myinfo.page";
}
// 회원 탈퇴 요청 (구현 필요)
@PostMapping("/resign.do")
public String resign(@RequestParam("userPwd") String userPwd, RedirectAttributes redirectAttributes, HttpSession session) {
// TODO: 회원 탈퇴 로직 구현
// 1. 세션에서 현재 로그인 사용자 정보 가져오기
// 2. 입력받은 userPwd와 실제 사용자 비밀번호 비교
// 3. 비밀번호 일치 시 서비스 호출하여 회원 탈퇴 (DB 삭제/상태 변경)
// 4. 성공 시 세션 무효화 및 성공 메시지, 실패 시 실패 메시지
redirectAttributes.addFlashAttribute("message", "회원 탈퇴 기능은 아직 구현되지 않았습니다.");
return "redirect:/user/myinfo.page";
}
}
- @Controller, @RequestMapping("/user"): 컨트롤러 빈으로 등록하고 /user로 시작하는 요청을 처리합니다.
- @GetMapping, @PostMapping: HTTP GET/POST 요청을 특정 URL과 메소드에 매핑합니다.
- @ResponseBody: 메소드의 반환 값을 HTTP 응답 본문에 직접 작성하도록 하여 AJAX 요청 처리에 유용합니다.
- RedirectAttributes: 리다이렉트 시 데이터를 일회성으로 전달(addFlashAttribute)할 때 사용합니다. URL에 노출되지 않고 세션에 잠시 저장된 후 리다이렉트 후 소멸됩니다.
- HttpSession: 세션에 사용자 정보(loginUser)를 저장하거나 무효화할 때 사용합니다.
- @RequestParam: HTTP 요청 파라미터를 메소드 인수로 바인딩합니다. 파일 업로드 시 MultipartFile로 받습니다.
- 뷰 리졸버: signupPage()나 myinfoPage()처럼 void나 String을 반환하는 메소드는 Spring Boot의 기본 뷰 리졸버(Thymeleaf 또는 JSP)에 따라 src/main/resources/templates/user/signup.html과 같은 경로의 템플릿 파일을 찾습니다.
🧪 컨트롤러 테스트 (MockMvc)
MockMvc는 스프링 컨테이너를 실행하지 않고도 Spring MVC의 요청-응답 흐름을 시뮬레이션할 수 있는 강력한 테스트 도구입니다. 실제 HTTP 요청을 보내는 것처럼 컨트롤러, 서비스, 매퍼까지의 전체 흐름을 테스트할 수 있어 각 계층의 연동 및 최종 결과 도출을 검증하는 데 매우 유용합니다.
웹 환경을 모방해서 테스트 수행
HTTP 요청을 가상으로 보내고 controller-service-Mapper 까지 실제 동작 검증 가능각 계층의 연결 및 원하는 결과 도출 확인 가능
주 메소드
- perform() : 요청 시뮬레이션,MockMvcRequestBuilder 객체를 통한 HTTP 요청 보냄
- andExpect() : 응답 검증, 응답 상태 코드/응답 본문 등 검증 메소드
- andDo() : 요청-응답 결과를 콘솔에 출력하여 확인하는 메소드
- andReturn() : 요청-응답 결과를 MvcResult 객체로 반환하는 메소드
package com.ino.app.controller; // 적절한 패키지명으로 변경
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ino.app.dto.UserDto;
import com.ino.app.service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; // 컨트롤러 테스트에 최적화
import org.springframework.boot.test.mock.mockito.MockBean; // 서비스 Mocking
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession; // Mock 세션 사용
import org.springframework.test.web.servlet.MockMvc;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
@WebMvcTest(UserController.class) // UserController만 로드하여 테스트
class UserControllerTest {
@Autowired
private MockMvc mockMvc; // MockMvc 주입
@MockBean // UserService를 Mock 객체로 주입
private UserService userService;
private MockHttpSession session; // 테스트용 세션
@BeforeEach // 각 테스트 메소드 실행 전 초기화
void setUp() {
session = new MockHttpSession(); // 새 Mock 세션 생성
// 로그인 상태를 모의하기 위해 세션에 UserDto 추가 (필요시)
UserDto loggedInUser = UserDto.builder()
.userId("testUser")
.userName("테스트유저")
.profileURL("/resources/images/testProfile.png")
.build();
session.setAttribute("loginUser", loggedInUser);
}
@Test
@DisplayName("회원가입 페이지 요청 테스트")
void signupPageTest() throws Exception {
mockMvc.perform(get("/user/signup.page")) // GET 요청
.andExpect(status().isOk()) // HTTP 상태 코드 200 OK 예상
.andExpect(view().name("user/signup")) // 반환되는 뷰 이름 검증
.andDo(print()); // 요청/응답 상세 로그 출력
}
@Test
@DisplayName("아이디 중복 체크 - 사용 가능")
void idcheck_notUsed() throws Exception {
// Given
String checkId = "newId";
when(userService.getUserCount(checkId)).thenReturn(0); // 서비스가 0을 반환하도록 Mocking
// When & Then
mockMvc.perform(get("/user/idcheck.do")
.param("checkId", checkId)) // 쿼리 파라미터 추가
.andExpect(status().isOk())
.andExpect(content().string("NOTUSED")) // 응답 본문이 "NOTUSED"인지 검증
.andDo(print());
}
@Test
@DisplayName("아이디 중복 체크 - 사용 중")
void idcheck_used() throws Exception {
// Given
String checkId = "existingId";
when(userService.getUserCount(checkId)).thenReturn(1); // 서비스가 1을 반환하도록 Mocking
// When & Then
mockMvc.perform(get("/user/idcheck.do")
.param("checkId", checkId))
.andExpect(status().isOk())
.andExpect(content().string("USED"))
.andDo(print());
}
@Test
@DisplayName("회원가입 성공")
void signup_success() throws Exception {
// Given
UserDto newUser = UserDto.builder()
.userId("signupTest")
.userPwd("password123")
.userName("가입자")
.build();
Map<String, Object> serviceResponse = new HashMap<>();
serviceResponse.put("message", "Success: 회원가입이 완료되었습니다.");
when(userService.registUser(any(UserDto.class))).thenReturn(serviceResponse); // 서비스가 성공 응답을 반환하도록 Mocking
// When & Then
mockMvc.perform(post("/user/signup.do")
.param("userId", newUser.getUserId())
.param("userPwd", newUser.getUserPwd())
.param("userName", newUser.getUserName())
.with(request -> { // MultipartFile이 아닌 일반 폼 데이터 전송
request.setMethod("POST");
return request;
}))
.andExpect(status().is3xxRedirection()) // 302 Redirect 예상
.andExpect(redirectedUrl("/")) // 루트 경로로 리다이렉트 되는지 검증
.andExpect(flash().attributeExists("message")) // Flash 속성 "message" 존재 여부 검증
.andExpect(flash().attribute("message", "Success: 회원가입이 완료되었습니다.")) // Flash 속성 값 검증
.andDo(print());
}
@Test
@DisplayName("로그인 성공")
void signin_success() throws Exception {
// Given
UserDto loginAttemptUser = UserDto.builder().userId("testUser").userPwd("testPwd").build();
UserDto loggedInUser = UserDto.builder().userId("testUser").userName("테스트유저").build();
Map<String, Object> serviceResponse = new HashMap<>();
serviceResponse.put("message", "테스트유저님 환영합니다!");
serviceResponse.put("user", loggedInUser);
when(userService.loginUser(any(UserDto.class))).thenReturn(serviceResponse);
// When & Then
mockMvc.perform(post("/user/signin.do")
.param("userId", loginAttemptUser.getUserId())
.param("userPwd", loginAttemptUser.getUserPwd())
.session(session)) // Mock 세션 사용
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"))
.andExpect(flash().attributeExists("message"))
.andExpect(flash().attribute("message", "테스트유저님 환영합니다!"))
.andExpect(request().sessionAttribute("loginUser", loggedInUser)) // 세션에 user 객체 저장되었는지 검증
.andDo(print());
}
@Test
@DisplayName("로그아웃 성공")
void signout_success() throws Exception {
mockMvc.perform(get("/user/signout.do")
.session(session)) // 로그인된 세션으로 요청
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"))
.andExpect(request().sessionAttributeDoesNotExist("loginUser")) // 세션에서 loginUser 속성이 사라졌는지 검증
.andDo(print());
}
@Test
@DisplayName("프로필 이미지 변경 성공")
void modifyProfile_success() throws Exception {
// Given
// MockMultipartFile을 사용하여 파일 업로드 시뮬레이션
MockMultipartFile mockFile = new MockMultipartFile(
"uploadFile", // 요청 파라미터 이름
"profile.png", // 파일 이름
MediaType.IMAGE_PNG_VALUE, // 파일 타입
"test image content".getBytes() // 파일 내용
);
Map<String, Object> serviceResponse = new HashMap<>();
serviceResponse.put("message", "Success: 프로필 이미지가 성공적으로 변경되었습니다.");
UserDto updatedUser = (UserDto) session.getAttribute("loginUser");
updatedUser.setProfileURL("/upload/new_profile.png"); // 업데이트될 URL 가정
serviceResponse.put("user", updatedUser);
// 서비스의 modifyUserProfile 메소드가 호출될 때 반환할 값 Mocking
when(userService.modifyUserProfile(anyString(), any(MultipartFile.class)))
.thenReturn(serviceResponse);
// When & Then
mockMvc.perform(multipart("/user/modifyProfile.do") // 파일 업로드는 multipart() 사용
.file(mockFile)
.session(session)) // 로그인된 세션으로 요청
.andExpect(status().isOk()) // @ResponseBody로 "SUCCESS"가 반환되므로 200 OK 예상
.andExpect(content().string("SUCCESS")) // 응답 본문 검증
.andExpect(request().sessionAttribute("loginUser", updatedUser)) // 세션의 user 객체가 업데이트되었는지 검증
.andDo(print());
}
}
위 테스트코드와 유사함.
MockMvcResultHandlers.print() 값
MockHttpServletRequest:
HTTP Method = POST
Request URI = /user/signin.do
Parameters = {userId=[sadsafa], userPwd=[asdf]}
Headers = []
Body = null
Session Attrs = {org.springframework.web.servlet.support.SessionFlashMapManager.FLASH_MAPS=[FlashMap [attributes={message=Error : not matched ID}, targetRequestPath=/, targetRequestParams={}]]}
Handler:
Type = com.ino.app.controller.UserController
Method = com.ino.app.controller.UserController#signin(UserDto, RedirectAttributes, HttpSession)
Async:
Async started = false
Async result = null
Resolved Exception:
Type = null
ModelAndView:
View name = redirect:/
View = null
Model = null
FlashMap:
Attribute = message
value = Error : not matched ID
MockHttpServletResponse:
Status = 302
Error message = null
Headers = [Content-Language:"en", Location:"/"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = /
Cookies = []
위와 같이 출력되는걸 볼 수 있음.
- @WebMvcTest(UserController.class): @SpringBootTest보다 경량화된 테스트 어노테이션으로, 웹 계층(컨트롤러) 테스트에 최적화되어 있습니다. UserController와 관련된 빈만 로드합니다.
- @MockBean: Spring Context에 존재하는 특정 빈(여기서는 UserService)을 Mock 객체로 대체하여 주입합니다. 이를 통해 실제 서비스 레이어를 호출하지 않고 테스트에 필요한 결과를 Mocking할 수 있습니다. 이는 컨트롤러 테스트의 독립성을 보장합니다.
- MockMvc: HTTP 요청을 시뮬레이션하고 응답을 검증하는 핵심 객체입니다.
- mockMvc.perform(요청Builder): 요청을 생성합니다. (get, post, multipart 등)
- .param("name", "value"): 요청 파라미터를 추가합니다.
- .session(session): Mock 세션을 요청에 포함합니다.
- .andExpect(결과Matcher): 예상되는 HTTP 응답 상태, 뷰 이름, 모델 속성, 리다이렉트 URL 등을 검증합니다.
- status().isOk(): 200 OK 상태 검증
- status().is3xxRedirection(): 리다이렉션 상태 (3xx) 검증
- view().name("..."): 반환될 뷰 이름 검증
- redirectedUrl("..."): 리다이렉트될 URL 검증
- flash().attributeExists("..."): Flash 속성 존재 여부 검증
- content().string("..."): 응답 본문 내용 검증
- request().sessionAttribute("...", val): 세션 속성 검증
- .andDo(print()): 요청과 응답의 상세 정보를 콘솔에 출력하여 디버깅에 유용합니다.
- @BeforeEach: 각 테스트 메소드가 실행되기 전에 수행될 초기화 로직을 정의합니다. 여기서는 MockHttpSession을 생성하고 로그인 상태를 모의합니다.
- Mockito.when(...).thenReturn(...): Mock 객체(userService)의 특정 메소드가 호출될 때 반환할 값을 정의합니다. 이를 통해 실제 서비스 로직이 아닌 Mock의 정의된 응답을 받게 됩니다.
- MockMultipartFile: 파일 업로드 테스트 시 MultipartFile을 모의하는 데 사용됩니다.
뷰 단 작업
JSP → html(thymeleaf)
WebConfig 파일 추가
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/*
WebMvcConfigurer
인터셉터 등록
리소스 핸들링
뷰 리졸버 세팅
메세지 변환
등등
*/
public void addResourceHandlers(ResourceHandlerRegistry registry){
// servlet-context.xml - <mvc:resource mapping="/upload/**" location="file:///upload/"/>
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:///upload/");
}
}
파일 용량 제한 설정
application.properties 파일을 통해 수정을 하면된다.
spring.servlet.multipart.max-file-size=10MB → 파일 한개당 최대 크기
spring.servlet.multipart.max-request-size=100MB → 파일 전체 최대 크기