멘토링에서 배운 Spring 유지보수 기법(Legacy, Eclipse 기준)
멘토링에서 배운 Spring 유지보수 기법(Legacy, Eclipse 기준)
멘토링으로 배운 것 중 유지보수 관점으로 묶을 수 있는 것을 정리하였습니다. 여전히 부족한 점이 많으니 참고하여 읽어주시면 감사하겠습니다.
1. Spring의 3-tier방식
-
스프링은 controller, dao, service가 서로 맞물리는 구조입니다.
-
Controller는 Presentation-tier(화면), Service는 Business-tier(로직), Dao는 Persistence-tier(데이터)입니다.
-
데이터를 받아오고(Dao), 데이터를 처리할 방식을 만들고(Service), 데이터를 화면에 연결(Controller)시킵니다.
-
3계층으로 구분시킨 이유는 각 영역을 독립시켜 유지보수에 용이하게 하기 위해서입니다.
-
flowchart LR
A[Controller]
B[Service]
C[Dao]
D[(Database)]
A-->B-->C-->D-->A
그러한 관점에서 아래 코드는 controller에 business logic이 들어가 있고 dao도 호출하고 있기에, 적절하지 못한 소스코드 입니다.
Business logic은 Service 계층으로 넣고, Dao 또한 Service 계층에서 호출되는 구조로 변경되어야 합니다.
@RestController
@PostMapping("/api/boards/insert")
public void insert(@Valid BoardDTO insertDto, MultipartHttpServletRequest multi)
throws IllegalStateException, IOException {
MultipartFile file = multi.getFile("file");
String contentType = file.getContentType();
if (!contentType.contains("image/jpeg") && !contentType.contains("image/png")) {
throw new Error("이미지타입이 잘못되었습니다");
}
String osName = System.getProperty("os.name").toLowerCase();
if (osName.contains("win")) {
String path = "c:/upload/";
File newFile = new File(path + file.getOriginalFilename());
if (!newFile.exists()) {
file.transferTo(newFile);
}
} else if (osName.contains("nix") || osName.contains("nux") || osName.contains("aix")) {
String path= "/usr/local/apache-tomcat-8.5.85/webapps/upload";
File newFile= new File(path + file.getOriginalFilename());
if (!newFile.exists()){
file.transferTo(newFile);
}
}
insertDto.setBoardPhoto(file.getOriginalFilename());
boardDao.insert(insertDto);
}
3-tier 요구사항을 반영하여 아래와 같이 변화를 줄 수 있습니다.
Business-tier에 해당하는 Service class를 만들고 거기에 logic을 만듭니다. Service class에서 필요한 경우 Dao를 호출합니다.
저는 boardDao.insert(insertDto);로 게시물 등록 쿼리문을 호출했습니다.
@Service
public void insert(@Valid BoardDTO insertDto, MultipartHttpServletRequest multi)
throws IllegalStateException, IOException {
MultipartFile file = multi.getFile("file");
String contentType = file.getContentType();
if (!contentType.contains("image/jpeg") && !contentType.contains("image/png")) {
throw new Error("이미지타입이 잘못되었습니다");
}
String osName = System.getProperty("os.name").toLowerCase();
if (osName.contains("win")) {
String path = "c:/upload/";
File newFile = new File(path + file.getOriginalFilename());
if (!newFile.exists()) {
file.transferTo(newFile);
}
} else if (osName.contains("nix") || osName.contains("nux") || osName.contains("aix")) {
String path= "/usr/local/apache-tomcat-8.5.85/webapps/upload";
File newFile= new File(path + file.getOriginalFilename());
if (!newFile.exists()){
file.transferTo(newFile);
}
}
insertDto.setBoardPhoto(file.getOriginalFilename());
boardDao.insert(insertDto);
}
그리고 서비스를 Presentation-tier에 해당하는 Controller class에서 호출합니다.
@RestController
@PostMapping("/api/boards/insert")
public ResponseEntity<String> insert(@Valid BoardDto insertDto, MultipartHttpServletRequest multi) throws ServiceException {
boardService.insertBoardInfo(insertDto, multi);
return new ResponseEntity<String>(HttpStatus.OK);
}
2. properties 파일을 활용해 하드 코딩 덜어내기
-
하드코딩이란 값을 직접 소스코드에 기입하여 사용하는 방식을 의미합니다.
- 하드 코딩을 하게 되면 일일이 찾아야 해 관리가 어려워지게 됩니다.
- 따라서 하드 코딩을 최대한 자제하고 별도의 설정파일 안에서 관리될 수 있게 하는 편이 유지보수에 좋습니다.
1번의 예제를 이어서 살펴보도록 하겠습니다.
아래의 Service class를 살펴보면 path가 하드코딩된 모습을 볼 수 있습니다.
@Service
public void insert(@Valid BoardDTO insertDto, MultipartHttpServletRequest multi)
throws IllegalStateException, IOException {
MultipartFile file = multi.getFile("file");
String contentType = file.getContentType();
if (!contentType.contains("image/jpeg") && !contentType.contains("image/png")) {
throw new Error("이미지타입이 잘못되었습니다");
}
String osName = System.getProperty("os.name").toLowerCase();
if (osName.contains("win")) {
String path = "c:/upload/";
File newFile = new File(path + file.getOriginalFilename());
if (!newFile.exists()) {
file.transferTo(newFile);
}
} else if (osName.contains("nix") || osName.contains("nux") || osName.contains("aix")) {
String path= "/usr/local/apache-tomcat-8.5.85/webapps/upload";
File newFile= new File(path + file.getOriginalFilename());
if (!newFile.exists()){
file.transferTo(newFile);
}
}
insertDto.setBoardPhoto(file.getOriginalFilename());
boardDao.insert(insertDto);
}
우선 path부터 바꿔보도록 하겠습니다. String path를 별도의 설정파일에서 받아오려면 properties파일이 필요합니다.
하지만 Legacy는 boot와는 다르게 properties파일을 읽어오는 설정을 직접 구현해야 합니다.
@Configuration
public class PropertyConfig {
@Bean
public PropertyPlaceholderConfigurer propertyConfigurer() {
PropertyPlaceholderConfigurer propertyConfigurer=new PropertyPlaceholderConfigurer();
ClassPathResource classpathResource=new ClassPathResource("local.properties");
propertyConfigurer.setLocation(classpathResource);
return propertyConfigurer;
}
}
Properties 파일을 읽는 configuration을 만들었다면, web.xml에서 읽어와야 합니다.
web.xml을 java 소스코드로 변형한 class의 경우, 아래와 같이 사용할 수 있습니다.
ApplicationContext가 configuration을 읽게끔 설정하는 작업입니다. 여기에 아래와 같이 PropertyConfig를 등록합니다.
public class MyWebAppInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.setConfigLocations("com.example.configuration.WebServletConfig","com.example.configuration.RootConfig","com.example.configuration.PropertyConfig"...);
.
.
.
}
}
그럼 이제 properties파일을 사용할 수 있습니다. 아래는 local.properties의 내용입니다.
path=/upload/
origin=http://localhost:3000
이제는 local.properties파일에서만 값을 바꾸면 다른 클래스에서 일일이 찾아가며 바꾸지 않게끔 작업할 차례입니다.
properties파일의 값은 @Value를 통해 가져올 수 있습니다. 가져올 value의 key를 ${}안에 넣어주면 됩니다.
@Service
public void insert(@Valid BoardDTO insertDto, MultipartHttpServletRequest multi)
throws IllegalStateException, IOException {
@value("${path}")
String path;
MultipartFile file = multi.getFile("file");
String contentType = file.getContentType();
if (!contentType.contains("image/jpeg") && !contentType.contains("image/png")) {
throw new Error("이미지타입이 잘못되었습니다");
}
String osName = System.getProperty("os.name").toLowerCase();
if (osName.contains("win")) {
File newFile = new File(path + file.getOriginalFilename());
if (!newFile.exists()) {
file.transferTo(newFile);
}
} else if (osName.contains("nix") || osName.contains("nux") || osName.contains("aix")) {
File newFile= new File(path + file.getOriginalFilename());
if (!newFile.exists()){
file.transferTo(newFile);
}
}
insertDto.setBoardPhoto(file.getOriginalFilename());
boardDao.insert(insertDto);
}
3. spring.profiles.active를 통해 local과 dev 분기처리
-
분기처리란 조건에 따라 다르게 값을 설정하는 행위를 의미합니다.
- 설정이 다르기에 필요한 환경, 시스템 변수값이 다릅니다.
- local과 linux서버의 차이로 매번 바꿔가는 커밋을 하면 불필요한 commit이 많아져 commit history 관리가 어려워집니다.
- 따라서 local과 dev는 분기처리가 필요합니다.
위에서는 linux일 때의 path를 설정하지 않았기에 배포서버에서 이미지 등록 시 경로를 찾지 못하는 오류가 발생하게 됩니다.
다행히 Spring.active.profiles를 통해 해당 문제를 해결할 수 있습니다.
2번예제 PropertyConfig class를 이어서 살펴보도록 하겠습니다.
2번과는 다르게 이번에는 String profile=System.getProperty(“spring.profiles.active”);라는 소스코드가 추가되었습니다.
System.getProperty()를 통해 vm argument의 값을 얻어올 수 있습니다.
그를 통해 local 모드라면 local.properties에서 값을 가져오게 하고, dev 모드라면 dev.properties에서 값을 가져오게 하면 됩니다.
@Configuration
public class PropertyConfig {
@Bean
public PropertyPlaceholderConfigurer propertyConfigurer() {
PropertyPlaceholderConfigurer propertyConfigurer=new PropertyPlaceholderConfigurer();
String profile=System.getProperty("spring.profiles.active");
ClassPathResource classpathResource=new ClassPathResource(profile+".properties");
propertyConfigurer.setLocation(classpathResource);
return propertyConfigurer;
}
}
window(local) eclipse에서는 아래와 같이 vm argument를 설정할 수 있습니다.
Servers Overview ㅡ> Open launch configuration ㅡ> Arguments ㅡ>-Dwtp.deploy=와 -add사이에 -Dspring.profiles.active=local 추가
-Dwtp.deploy="C:\data\spring\.metadata\.plugins\org.eclipse.wst.server.core\tmp0\wtpwebapps" -Dspring.profiles.active=local --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED
linux 서버(dev)에서는 tomcat directory의 bin폴더에 setenv파일을 만들어 설정합니다.
JAVA_OPTS="$JAVA_OPTS -Dspring.profiles.active=dev"
dev.properties파일은 서버에 맞게 설정합니다.
path=/usr/local/apache-tomcat-8.5.85/webapps/upload/
origin=http://dpms.openobject.net:11111111111
이제 Service class에서 더이상 소스코드로 window와 linux 분기를 만들 필요가 없습니다.
따라서 아래와 같이 소스코드 양이 줄어듭니다.
@Service
public void insert(@Valid BoardDTO insertDto, MultipartHttpServletRequest multi)
throws IllegalStateException, IOException {
@Value("${path}")
private String path;
MultipartFile file = multi.getFile("file");
String contentType = file.getContentType();
if (!contentType.contains("image/jpeg") && !contentType.contains("image/png")) {
throw new Error("이미지파일만 가능합니다.");
}
File newFile = new File(path + file.getOriginalFilename());
if (!newFile.exists()) {
try {
file.transferTo(newFile);
} catch (Exception e) {
log.info("{}", e.getMessage());
}
}
insertDto.setBoardPhoto(file.getOriginalFilename());
boardMapper.insert(insertDto);
}
4.@ControllerAdvice를 활용해 Exception을 전역처리
-
@ControllerAdvice는 Exception을 전역으로 관리하게 해주는 Spring의 기능입니다.
- Enum을 통해 Exception message를 하나의 파일안에서 관리하면 유지보수하기에 매우 간편할 것입니다.
- @ControllerAdvice를 이용해 Exception class 또한 모아놓고 하나의 파일에서 관리할 수 있습니다.
3번 예제에 이어서 살펴보도록 하겠습니다. 우선 Exception message부터 하나의 파일로 모아보겠습니다.
앞으로는 아래의 enum에서 오류메시지를 관리하게 됩니다. 개별 오류 메시지들을 알기 위해 모든 Service class를 훑어볼 필요가 없습니다.
@Getter
@RequiredArgsConstructor
public enum CommonErrorCode {
//Board
BOARD_BAD_TYPE("이미지파일만 가능합니다.")
private final String message;
}
그 다음으로 Exception class를 만들어보겠습니다.
Service에 필요한 Exception을 던지는 class라서 ServiceException이라고 이름지었습니다.
이제 ServiceException은 new로 생성될 때 반드시 String class type의 argument를 받아야 합니다.
@Getter
@RequiredArgsConstructor
public class ServiceException extends RuntimeException{
/**
*
*/
private static final long serialVersionUID = 1L;
private final String errorResponse;
}
3번 예제의 Service class와 다르게 Error가 아니라 직접 구현한 ServiceException을 던지게 바뀌었습니다.
메시지도 하드코딩된 메시지가 아니라 BOARD_BAD_TYPE.getMessage()와 같이 enum의 String 값을 받아오게 바뀌었습니다.
@Service
public void insert(@Valid BoardDTO insertDto, MultipartHttpServletRequest multi)
throws IllegalStateException, IOException {
@Value("${path}")
private String path;
MultipartFile file = multi.getFile("file");
String contentType = file.getContentType();
if (!contentType.contains("image/jpeg") && !contentType.contains("image/png")) {
throw new ServiceException(BOARD_BAD_TYPE.getMessage());
}
File newFile = new File(path + file.getOriginalFilename());
if (!newFile.exists()) {
try {
file.transferTo(newFile);
} catch (Exception e) {
log.info("{}", e.getMessage());
}
}
insertDto.setBoardPhoto(file.getOriginalFilename());
boardMapper.insert(insertDto);
}
그 다음으로는 전역으로 관리하기 위해 @ControllerAdvice를 만듭니다.
@ControllerAdvice를 달면 스프링 컨테이너에서 관리하는 대상(Component)이 됩니다.
@Rest가 달린 이유는 front framework를 쓰게되면 json으로 보내야 하기 때문입니다.
annotation이 RestController인 것만 대상으로 ServiceException을 잡아내게 됩니다.
@RestControllerAdvice(annotations = RestController.class)
public class ServiceExceptionAdvice{
@ExceptionHandler(ServiceException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ExceptionDto serviceException(HttpServletRequest request,ServiceException serviceException) {
ExceptionDto exceptionDto=new ExceptionDto();
exceptionDto.setRequestURL(request.getRequestURI());
exceptionDto.setMessage(serviceException.getErrorResponse());
return exceptionDto;
}
}
간단하게 ExceptionDto를 만들었습니다.
error 메시지를 어떻게 가공하여 프론트에 전송할할 것인가에 따라 더 많은 field가 필요할 수도 있습니다.
@Data
public class ExceptionDto {
private String requestURL;
private String message;
}
boot와 달리 자동으로 ComponentScan이 되지 않기 때문에 ComponentScan 대상임을 명시해주어야 합니다.
아래와 같이 WebServletConfig에 달아주었습니다.
@ComponentScan(basePackages = { "com.example.controller","com.example.exception" })
public class WebServletConfig implements WebMvcConfigurer {
.
.
}
5. 전역 Cors 허용 설정
-
cors는 리소스가 자신의 출처(도메인, 프로토콜, 포트)와 다를 때 교차 출처 HTTP 요청을 실행합니다.
- 기본적으로 브라우저는 교차출처 HTTP 요청을 제한합니다.
- 즉, 프론트 프레임워크를 따로 사용하게 되면 호스트가 바뀌게 되면서 서로 다른 출처로 인식되어 cors 오류가 납니다.
- 따라서 front 프레임워크를 사용하는 경우, 서버의 cors 설정에서 교차 출처를 허용해야 합니다.
먼저 @CrossOrigin을 사용할 수 있습니다.
다만 아래와 같이 모든 Controller class마다 달아주게 되면 cors가 바뀌면 매번 전부 다시 쳐서 바꿔주어야 합니다.
@CrossOrigin(origins="http://dpms.openobject.net:1111111111")
@RestController
public class BoardController {
.
.
.
}
따라서 annotation이 아닌 소스코드로 바꾸고, 전역으로 설정가능한 방식으로 변경합니다.
우선은 WebServletConfig에서 전역 설정이 가능합니다.
@Configuration
public class WebServletConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST")
.maxAge(3000);
}
}
여기서도 local.properties 파일을 활용해보겠습니다. 기억이 나지 않을 수 있으니 다시 가져왔습니다.
path=/upload/
origin=http://localhost:3000
@Value를 활용해 WebServletConfig를 아래와 같이 변경할 수도 있습니다.
@Configuration
public class WebServletConfig implements WebMvcConfigurer {
@Value("${origin}")
String origin;
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(origin)
.allowedMethods("GET", "POST")
.maxAge(3000);
}
}
또는 Spring Security와 Filter를 활용할 수도 있습니다. 우선 CorsFilter를 만들어줍니다.
CorsFilter는 아래와 같이 CorsConfig에서 bean으로 등록합니다.
bean이 되면 Spring Container의 관리 대상이 되기 때문에 DI가 가능해집니다.
@Configuration
@Slf4j
public class CorsConfig {
@Value("${origin}")
private String origin;
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config=new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin(origin);
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
CorsFilter를 만들고 bean으로 등록시켰으므로 이제 DI를 해줄 수 있습니다.
이제부터 SecurityConfig는 CorsFilter를 주입받아 CorsFilter에 서술된 origin(localhost:3000) 사이트는 cors를 허용해주게 됩니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@ComponentScan(basePackages = { "com.example.authentication" })
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CorsFilter corsFilter;
.
.
.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.
.
.
.addFilter(corsFilter)
.
.
.
}
}
boot와는 달리 Filter를 만들었다면 아래와 같이 web.xml(혹은 그에 준하는 class config)에 등록시켜주는 과정이 필요합니다.
public class MyWebAppInitializer implements WebApplicationInitializer {
.
.
.
private void addIncodingFilter(ServletContext servletContext){
.
.
.
DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy();
FilterRegistration.Dynamic springSecurity = servletContext.addFilter("springSecurityFilterChain", springSecurityFilterChain);
springSecurity.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*");
}
}
6. encoding 전역 설정하기
-
content-type은 자원의 형식을 명시하기 위해 헤더에 실리는 정보입니다.
- content-type에서 charset을 잘못 설정하면 한글의 경우 인코딩이 깨져서 가는 경우가 종종 발생합니다.
- 따라서 한글이 깨지는 경우, encoding 방식을 바꿔야합니다.
이번에는 게시물 등록(insert)이 아니라 게시물 목록 조회(list)로 가져왔습니다.
content-type을 명시하기 위해 아래와 같이 header를 넣을 수 있습니다.
@GetMapping("/api/boards")
public ResponseEntity<String> fetchList(int page, String searchType, String keyword, HttpServletResponse response)
throws JsonProcessingException {
BoardListResponse boardListResponse = new BoardListResponse();
HttpHeaders header = new HttpHeaders();
header.add("content-type", "application/json; charset=utf-8");
boardListResponse.setBoardList(boardService.getList(page, searchType, keyword));
boardListResponse.setBoardListTotal(boardService.getLast(searchType, keyword));
return new ResponseEntity<String>(new ObjectMapper().writeValueAsString(boardListResponse), header, HttpStatus.OK);
}
또는 아래와 같이 header 설정 대신 produces를 활용할 수도 있습니다.
produces도 본질적으로 header를 설정하는 것이지만 약간 더 소스코드가 짧아집니다.
하지만 두 가지 모두 각 method마다 전부 동일하게 적어줘야 한다는 단점이 있습니다.
@GetMapping(value = "/api/boards", produces = "application/json;charset=utf-8")
public ResponseEntity<String> fetchList(int page, String searchType, String keyword, HttpServletResponse response)
throws JsonProcessingException {
BoardListResponse boardListResponse = new BoardListResponse();
boardListResponse.setBoardList(boardService.getList(page, searchType, keyword));
boardListResponse.setBoardListTotal(boardService.getLast(searchType, keyword));
return new ResponseEntity<String>(new ObjectMapper().writeValueAsString(boardListResponse), header, HttpStatus.OK);
}
따라서 아래와 같이 messageConverter를 활용한다면 한 곳에서 전역의 encoding을 관리할 수 있습니다.
@ComponentScan(basePackages = { "com.example.controller","com.example.exception" })
public class WebServletConfig implements WebMvcConfigurer {
.
.
.
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
StringHttpMessageConverter stringConverter = new StringHttpMessageConverter();
stringConverter.setSupportedMediaTypes(Arrays.asList(new MediaType("application", "json", Charset.forName("UTF-8"))));
converters.add(stringConverter);
}
}
또는 build 단계에서 encoding을 utf-8로 설정하는 것도 방법입니다. 경우에 따라서는 두 방법 모두 동원해야 할 수도 있습니다.
<properties>
<project.build.sourceEncoding>utf-8</project.build.sourceEncoding>
<java-version>1.8</java-version>
.
.
.
</properties>
여태까지 배우고 학습한 유지보수 기법을 예제를 통해 정리해보았습니다. 읽어주셔서 감사합니다.
다들 남은 한 해 건승하시고 행복하시길 소망합니다.
Leave a comment