정말 간단한 연락처 관리 프로그램이므로 이름과 연락처 정보만 담을 예정이고 개인정보 암호화 같은건 다루지 않습니다.
입력받은 정보 그대로 string으로 저장을 하도록 하겠습니다.
이렇게 테이블 설계가 완료되면 이제 백엔드에서 쿼리해올 수 있도록 구현을 들어가보겠습니다.
백엔드 개발
우선 새로운 프로젝트를 만들어야겠죠.
아래 순서대로 새로운 프로젝트를 만들어 줍니다.
Project Explorer > New > Project 선택
Wizards에서 spring으로 검색한 뒤 Spring Starter Prject 선택 (이게 안나온다면 STS설치하기부터 하고 오시면 됩니다.)
Name 항목은 프로젝트명을 입력하는 곳입니다.
여기에 contact-manager 라고 넣어주시고 Java Version은 8 이상으로 아무거나 쓰셔도 무방할겁니다.
여기서는 Java 11 버전을 사용하였습니다.
Next를 눌러 다음으로 넘어간 뒤 Available 검색창에서 spring web, lombok, mybatis, mysql를 검색하여 우측의 selected 항목에 세 개 항목이 들어가도록 체크하고 Finish 버튼을 클릭합니다. 저는 이미 사용한 적이 있어서 Frequently Used에 항목이 나타나서 체크만 해주었습니다. 처음 하시는 분들은 Available에서 검색하시면 됩니다.
우측 하단에 progress bar가 진행되는 것을 확인할 수 있습니다.
또는 Progress View에서 확인할 수도 있죠. Progress View가 안보인다면 이클립스 상단 메뉴에서 Window > Show View > Other > Progress로 검색하면 추가할 수 있습니다.
우선 위에서 만든 contact 테이블의 내용을 조회해오는 API를 만들어 보도록 하겠습니다.
com.example.demo 패키지 하위에 domain 라는 이름의 패키지를 만들고 해당 패키지에 ContactInfo.java 파일을 생성해줍니다.
그리고 아래와 같은 내용을 넣어줍니다.
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ContactInfo {
private String name;
private String phone;
}
ContactInfo 클래스는 contact 테이블과 연동할 dto입니다.
com.example.demo 패키지 하위에 mapper 라는 이름의 패키지를 만들고 해당 패키지에 ContactInfoMapper.java 파일을 생성해줍니다. ContactInfoMapper는 클래스가 아닌 인터페이스로 생성해주고 아래와 같이 쿼리문을 작성해줍니다.
import com.example.demo.domain.ContactInfo;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface ContactInfoMapper {
@Select("select * from contact")
List<ContactInfo> selectAll();
@Insert("insert into contact (name, phone) values (#{name}, #{phone})")
int insert(ContactInfo contactInfo);
@Delete("delete from contact where name = #{name}")
int delete(String name);
@Select("<script>" +
"select * from contact " +
"<where>" +
"<if test=\"name != null and name.length > 0\">and name = #{name}</if>" +
"<if test=\"phone != null and phone.length > 0\">and phone = #{phone}</if>" +
"</where>" +
"</script>")
List<ContactInfo> selectBy(@Param("name") String name, @Param("phone") String phone);
@Update("update contact " +
"set phone = #{phone} " +
"where name = #{name}")
int update(String name, String phone);
}
이 ContactInfoMapper 인터페이스는 마이바티스와 연동이 되어 쿼리를 database로 전달하고 결과를 받아오는 역할을 합니다.
com.example.demo 패키지 하위에 service 라는 이름의 패키지를 만들고 해당 패키지에 ContactInfoService.java 파일을 생성해줍니다.
이 파일에는 아래와 같이 내용을 넣어주시면 됩니다.
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.example.demo.domain.ContactInfo;
import com.example.demo.mapper.ContactInfoMapper;
@Service
public class ContactInfoService {
@Autowired
private ContactInfoMapper contactInfoMapper;
public List<ContactInfo> getEveryContactInfo() {
return contactInfoMapper.selectAll();
}
public int addContactInfo(ContactInfo contactInfo) {
return contactInfoMapper.insert(contactInfo);
}
public int delContactInfo(String name) {
return contactInfoMapper.delete(name);
}
public List<ContactInfo> getContactInfos(String name, String phone) {
return contactInfoMapper.selectBy(name, phone);
}
public int updateContactInfo(String name, String phone) {
return contactInfoMapper.update(name, phone);
}
}
ContactInfoMapper의 메서드를 호출하는 역할만 하고 있지만 프로젝트가 복잡해지면 각종 비즈니스 로직이 들어가야할 클래스입니다.
조회한 뒤에 정렬을 한다든지, 필터링을 한다든지 하는 로직은 이 서비스 클래스에 넣어주시면 됩니다.
이제 controller쪽에서 사용할 응답 도메인 클래스를 하나 더 만들어 보겠습니다.
com.example.demo.domain 패키지에 Response.java 파일을 생성하고 아래와 같이 작성해줍니다.
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@JsonInclude(Include.NON_NULL)
public class Response<T> {
private int code;
private String message;
private T data;
public Response(int code, String message) {
this.code = code;
this.message = message;
}
public Response(int code, String message, T data) {
this(code, message);
this.data = data;
}
}
마지막으로 com.example.demo 패키지 하위에 controller 라는 이름의 패키지를 만들고 해당 패키지에 ContactInfoController.java 파일을 생성해서 아래 내용을 넣어주세요.
import com.example.demo.domain.ContactInfo;
import com.example.demo.domain.Response;
import com.example.demo.service.ContactInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/contact-info")
public class ContactInfoController {
@Autowired
private ContactInfoService contactInfoService;
@GetMapping("/list") // --> localhost:8080/contact-info/list
public List<ContactInfo> getAllContactInfo() {
return contactInfoService.getEveryContactInfo();
}
@GetMapping("/get")
public ResponseEntity<Response> getContactInfos(
@RequestParam(required = false) String name,
@RequestParam(required = false) String phone) {
List<ContactInfo> result = contactInfoService.getContactInfos(name, phone);
if (result.size() == 0) {
return ResponseEntity.status(HttpStatus.OK).body(new Response(204, "데이터 없음"));
}
return ResponseEntity.ok(new Response(200, "조회 성공", result));
}
@PostMapping("/add")
public ResponseEntity<Response> addNewContactInfo(@RequestBody ContactInfo contactInfo) {
contactInfoService.addContactInfo(contactInfo);
return ResponseEntity.ok(new Response(200, "등록 성공"));
}
@DeleteMapping("/del")
public ResponseEntity<Response> delContactInfo(@RequestParam String name) {
int result = contactInfoService.delContactInfo(name);
if (result == 0) {
return ResponseEntity.status(HttpStatus.OK).body(new Response(204, "데이터 없음"));
}
return ResponseEntity.ok(new Response(200, "삭제 성공"));
}
@PutMapping("/update")
public ResponseEntity<Response> updateContactInfo(@RequestParam String name, @RequestParam String phone) {
int result = contactInfoService.updateContactInfo(name, phone);
if (result == 0) {
return ResponseEntity.status(HttpStatus.OK).body(new Response(204, "데이터 없음"));
}
return ResponseEntity.ok(new Response(200, "수정 성공"));
}
@ExceptionHandler
public ResponseEntity<Response> contactInfoErrorHandler(Exception e) {
log.error("error!!, {}", e.getClass().getName(), e);
if (e instanceof DuplicateKeyException) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(new Response(409, "이미 중복된 이름이 등록되어있습니다."));
} else {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new Response(500, "서버오류가 발생하였습니다."));
}
}
}
이 클래스가 프론트와 백엔드를 연결해주는 부분으로 API url을 정의하고 어떤 파라미터들을 전달받아야 하는지, 응답은 어떻게 주고 예외처리는 어떻게 하는지에 대한 내용이 담겨있죠.
이제 백엔드 구현은 완료되었습니다. 아 참!! 하나 빼먹은게 있네요. database 접속 정보를 빼먹었습니다. 자, src/main/resources 하위의 application.properties 파일을 열어서 직접 생성하셨던 database 접속 정보를 아래처럼 넣어주세요.
접속 포트(8889)와 database명(javastudy) 그리고 username, password 정보를 생성하신 데이터베이스에 맞게 변경해주셔야 합니다.
여기까지 완료되었다면 Project Explorer에 아래와 같이 파일들이 존재해야 합니다.
이제 ContactManagerApplication을 Sppring Boot App으로 기동해서 API가 잘 동작하는지 확인을 해보도록 하겠습니다.
앱 기동에 성공하면 Console View에서 아래 로그를 확인 할 수 있습니다.
INFO 69998 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
INFO 69998 --- [main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
INFO 69998 --- [main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.37]
INFO 69998 --- [main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
INFO 69998 --- [main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1185 ms
INFO 69998 --- [main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor'
INFO 69998 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
INFO 69998 --- [main] c.e.demo.ContactManagerApplication : Started ContactManagerApplication in 2.457 seconds (JVM running for 3.521)
그리고 controller 패키지 내에 ViewController.java 파일을 만들어서 아래와 같이 짧은 코드를 작성해주겠습니다.
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ViewController {
@GetMapping("/contact-info")
public String contactInfo() {
return "contact-info"; // contact-info.html을 화면에 보여주는 역할
}
}
그리고 src/main/resources 하위의 templates 디렉토리에 contact-info.html 파일을 하나 생성하고 아래 내용을 넣어주세요.
오늘은 backend 보다는 frontend 작업을 위한 Bootstrap 추가를 webjar를 이용할 수 있도록 설정해보도록 할 예정입니다.
저는 backend 개발자다보니 front쪽은 자세히 알려드리기는 힘들지만 빠르게 서치해서 사용해본 경험상 webjar는 maven 의존성 관리를 통해 라이브러리를 다운받아 사용할 수 있어 매우 쉽게 라이브러리 추가가 가능합니다. org.webjars에는 bootstrap, jquery, font-awesome, swagger-ui 뿐만 아니라 angularJS, momentJS, d3js 등 매우 많은 프론트 개발을 위한 라이브러리들을 제공하고 있습니다. 매번 다운로드받아서 프로젝트에 추가할 필요 없이 백엔드 라이브러리들을 관리하듯이 메이븐 의존성으로 쉽게 관리할 수 있도록 도와주는 역할을 한다고 보시면 됩니다.
webjar 설정을 위해서 추가할 파일은 딱 하나입니다. org.springframework.web.servlet.config.annotation.WebMvcConfigurer를 구현하는 WebMVCConfig 클래스를 하나 생성하고 다음과 같이 addResourceHandlers 메서드를 오버라이드하여 작성해줍니다.
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (!registry.hasMappingForPattern("/webjars/**")) {
registry.addResourceHandler("/webjars/**").addResourceLocations(
"classpath:/META-INF/resources/webjars/");
}
if (!registry.hasMappingForPattern("/**")) {
registry.addResourceHandler("/**").addResourceLocations(
new String[] {"classpath:/static/script", "classpath:/static/style"});
}
}
우선 webjars 경로를 등록해주는 것 때문에 이 클래스를 만들어 사용하는데 이때 @EnableWebMvc 어노테이션을 사용하게 됩니다. 그런데 여기서 주의할 점이 이 어노테이션을 사용하게되면 WebMvcAutoConfiguration 이 disable되면서 기존 설정들이 정상적으로 동작하지 않게 됩니다. 따라서 기존에 잘 읽어오던 static하위의 javascript파일들과 css파일들을 못찾아 404 오류가 발생하게 되죠. 그래서 addResourceHandlers에 이 두 파일들이 존재하는 디렉토리를 classpath경로로 추가해줘야 합니다.
또한, application.properties 파일에 작성하였던 아래 두 라인은 더이상 사용하지 않고 방금 추가한 WebMVCConfig 클래스에 별도로 ViewResolver를 등록하여 사용해야합니다.
@Bean
public ViewResolver getViewResolver(){
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
resolver.setViewClass(JstlView.class);
return resolver;
}
아래는 완성된 WebMVCConfig 클래스의 코드입니다.
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
import org.springframework.web.servlet.view.JstlView;
@Configuration
@EnableWebMvc
public class WebMVCConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (!registry.hasMappingForPattern("/webjars/**")) {
registry.addResourceHandler("/webjars/**").addResourceLocations(
"classpath:/META-INF/resources/webjars/");
}
if (!registry.hasMappingForPattern("/**")) {
registry.addResourceHandler("/**").addResourceLocations(
new String[] {"classpath:/static/script", "classpath:/static/style"});
}
}
@Bean
public ViewResolver getViewResolver(){
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
resolver.setViewClass(JstlView.class);
return resolver;
}
}
자, 모든 준비는 완료가 되었습니다. 이제 pom.xml에 다음과 같이 부트스트랩 의존성을 추가해줍니다.
이제 새로운 프로젝트를 생성합니다. 현재 이클립스를 처음 설치하신 분이라면 패키지 탐색기(Project Explorer)에 아래와 같이 나오는데 여기서 밑에서 두 번째에 있는 Create a project...를 선택합니다. 만약 이미 프로젝트를 만들어 놓은게 있는 분들이라면 그냥 탐색기 창에서 우클릭해서 New > Project 를 선택하시면 됩니다.
아래와 같이 새 프로젝트 생성 마법사가 뜨면 spring 으로 검색을 해서 Spring Starter Project를 선택합니다.
이제 만들 프로젝트의 이름을 Name 항목에 적어줍니다. 그리고 Java Version 은 11로 선택해줍니다. (8로 해도 무방합니다)
Spring Boot의 버전을 선택할 수 있는데 이 부분은 그대로 놔두고 Available 검색창에서 web이라고 검색하여 Spring Web을 선택해줍니다. (이외에도 lombok이나 데이터 베이스 드라이버, MyBatis 등 유용한 기능들을 선택하여 사전설치가 가능합니다만 여기서는 선택하지 않습니다.)
그리고 pom.xml 파일에 아래 의존성을 추가해줍니다. jasper는 JSP 파일을 컴파일 해주는 기능을 합니다.
<!-- Need this to compile JSP -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<scope>provided</scope>
</dependency>
이제 프로젝트명에서 우클릭하고 "src/main/webapp" 새 폴더를 하나 만듭니다. 이렇게 폴더를 만들면 아래와 같이 생성됩니다.
이제 다시 우클릭하여 build path 설정(Configure Buildpath...)화면으로 들어갑니다.
Add Folder... 를 클릭하고 webapp 디렉토리를 찾아 체크해줍니다.
OK 버튼을 누르고 Apply하면 프로젝트 구조가 아래와 같이 바뀐것을 확인할 수 있습니다.
이제 webapp 아래에 WEB-INF디렉토리를 생성하고 하위에 또 views 라는 디렉토리를 생성합니다. 그리고 test.jsp 파일을 만들어 아래와 같이 작성해줍니다.
자, 이제 마지막으로 해당 페이지와 연결할 API를 작성합니다. root package에서 controller 라는 패키지를 하나 만들고 그 아래에 DemoController.java를 아래와 같이 작성합니다.
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DemoController {
@GetMapping("/test")
public String login() {
return "/test";
}
}
여기까지 완료되었으면 이제 Run As > Java Application 또는 Run As > Spring Boot App 으로 기동시켜줍니다.
그리고 localhost:8080/test 에 접속하면 아래와 같이 jsp 페이지가 뜨는 것을 확인할 수 있습니다.
JSP 페이지에 javascript 및 css 파일 연동
이제 JSP 페이지에 javascript 파일 및 css 파일을 연결시켜보겠습니다.
javascript와 css 파일은 src/main/resources 하위의 static 폴더 안쪽에 몰아넣어주면 됩니다.
우선 static 폴더 하위에 script 폴더를 만들어 test.js파일을 생성하고 아래 내용을 작성합니다.
$(document).ready(function() { printCurrentDatetime(); });
function printCurrentDatetime() {
let date = new Date();
$('#currentTime').html(date);
setTimeout(printCurrentDatetime, 1000);
}
그리고 static 폴더 하위에 style 폴더를 만들어 test.css 파일을 생성하고 아래와 같이 작성합니다.
@charset "UTF-8";
body {background-color:cornflowerblue;}
자, 더이상 파일을 만들 필요는 없습니다. 마지막으로 JSP 파일에 위에서 작성한 두 파일을 연결시켜주겠습니다.
MySQL 8 버전(mysql-8.0.20-macos10.15)을 MacOS 10.14 (Mojave) 에 설치했다. 설치는 CE버전 dmg 파일을 다운받아서 했는데, 설치할 때 마지막에 인스톨 프로세스 종료 후 자동으로 MySQL을 시작할건지에 대한 체크를 해제하지 않고 finish했고, MAMP가 실행되지 않길래(팝업창이 뜨지 않길래) 애플리케이션에서 수동으로 실행을 시켰는데 nginx 서버만 뜨고 mysql 서버가 뜨지 않았다.
오랜만에 MAMP를 쓰려다보니 왜 안뜨는지 원인을 찾기가 힘들어서 정리해본다.
일단 mysql 서버의 로그를 확인해보려 했다. 로그 위치가 어디인지를 몰라서 그냥 find 명령어로 mysql*.log 파일을 찾으려했는데 안나온다 -_-; 그래서 구글링을 해봤더니 .err 확장자로 끝나는 파일이 MAMP 안에 log 디렉토리 안에 있었고(파일명은 mysql_error_log.err 이다) 해당 파일을 열어서 에러메시지를 확인했더니 해당 포트가 이미 사용중이란다. 오잉? 그럼 설치후에 실행이 됐다는 얘긴가??? 그래서 프로세스를 확인해보았다.
ps -ef|grep mysql
그랬더니 mysql 데몬이 이미 떠있다. 실행시킨 유저는 _mysql 이라고 되어있었고 이 프로세스를 끄고 다시 MAMP를 실행시켜서 mysql 서버를 띄우려고 kill -9 PID를 실행시켰는데 해당 프로세스는 꺼졌으나 다른 PID를 갖는 mysql데몬이 자동으로 실행이 되어있었다. ㅡㅡ;
뭐지?? 얘 좀비네? 다시 열심히 구글링을 해서 동일한 문제에 대해 설명을 잘 해놓은 미디엄 포스팅을 하나 찾았다. 바로 여기이다. 해당 사이트에서는 mysql-8.0.12-macos10.13 버전에 대한 설명이 있었고 내가 설치한 버전은 mysql-8.0.20-macos10.15 버전이었다. 아마 macOS 버전도 다르지 않을까 싶은데 아무튼 저 사이트의 설명대로 해도 해당 프로세스는 죽었다 살아나고 죽었다 살아나고를 반복했다.
2.4.3 Installing and Using the MySQL Launch Daemon
macOS uses launch daemons to automatically start, stop, and manage processes
and applications such as MySQL.
By default, the installation package (DMG) on macOS installs a launchd file named
/Library/LaunchDaemons/com.oracle.oss.mysql.mysqld.plist that contains a plist
definition similar to:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.oracle.oss.mysql.mysqld</string>
<key>ProcessType</key> <string>Interactive</string>
<key>Disabled</key> <false/>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>SessionCreate</key> <true/>
<key>LaunchOnlyOnce</key> <false/>
<key>UserName</key> <string>_mysql</string>
<key>GroupName</key> <string>_mysql</string>
<key>ExitTimeOut</key> <integer>600</integer>
<key>Program</key> <string>/usr/local/mysql/bin/mysqld</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/mysql/bin/mysqld</string>
<string>--user=_mysql</string>
<string>--basedir=/usr/local/mysql</string>
<string>--datadir=/usr/local/mysql/data</string>
<string>--plugin-dir=/usr/local/mysql/lib/plugin</string>
<string>--log-error=/usr/local/mysql/data/mysqld.local.err</string>
<string>--pid-file=/usr/local/mysql/data/mysqld.local.pid</string>
<string>--keyring-file-data=/usr/local/mysql/keyring/keyring</string>
<string>--early-plugin-load=keyring_file=keyring_file.so</string>
</array>
<key>WorkingDirectory</key> <string>/usr/local/mysql</string>
</dict>
</plist>
즉, mysql을 설치하면 /Library/LaunchDaemons/com.oracle.oss.mysql.mysqld.plist 파일에 위와 같은 설정 내용이 들어있다는 내용이었고 설정 항목들 중에 자동실행과 관련된 항목이 있을까 싶어 쭈욱 훑어보니 RunAtLoad 와 LaunchOnlyOnce 항목이 눈에 띄었다. 일단 둘다 기본값과 반대로 설정하여 RunAtLoad 값은 false로, LaunchOnlyOnce의 값은 true로 수정해서 저장하고 다시 kill을 해보았다. (이 과정에서 재부팅을 했었는지 정확히 기억이 나지는 않는다;; 안했던것 같은데..^^; ) 그랬더니 더이상 자동으로 실행되지 않았고, MAMP를 실행시켜서 mysql 서버를 start하니 이제 잘 동작하는 것을 확인할 수 있었다.
사내에서 테이블 데이터 수집을 위해서 테이블 만들 때 auto increment 컬럼을 pk로 추가해달라는 요청이 있어서 테이블 생성시 꼭 추가하고 있다. 그리고 그 auto increment 컬럼의 값은 실제로 데이터가 저장(insert)될 때만 올라갈 거라고 지레짐작만 하고 있었는데 이번에 어떤 서비스를 빨리 개발해줘야해서 새로운 테이블을 만들어 테스트하다가 auto increment값이 순서대로 올라가지 않는 현상을 보게되었다.
테스트코드는 insert -> select -> update -> select 순으로 동작하도록 구성했고 동일한 테스트를 최소 두 번 이상 돌렸다. 이렇게 돌리니까 처음에는 당연히 동일한 데이터가 없어서 no값은 default로 1로 생성이 되었다. 하지만 똑같은 테스트케이스를 다시 돌리면 duplicate key exception이 발생하면서 내부적으로 auto increment값이 증가하지 않을 줄 알았으나, 예외가 발생하여 데이터를 저장하지 못한다해도 insert가 시도될 때마다 no값이 증가하는 것을 확인할 수 있었다. auto increment값이 증가하는 케이스를 정리하자면 다음과 같다.
1. insert가 시도되면 no가 증가하게 된다.
2. duplication key 예외가 발생한다해도 증가하게 된다.
3. update시에는 증가하지 않았다.
일부 테이블은 많은 양의 insert on duplicate key update 문을 실행하고있는데 auto increment값이 overflow 되지 않을까 염려되어 현재 max no값을 조회해보니 아직 수 년은 버틸 수는 있을 정도였다.
만약 overflow 될정도로 많이 올라간다면 어떻게 해야할까 ??? no 값을 다시 1로 세팅하여 처음부터 시작하도록 할 수 있기는 하다. 아래 쿼리를 실행시키면 다시 1부터 세팅을 해준다.
ALTER TABLE YOUR_TABLE_NAME AUTO_INCREMENT=1;
SET @COUNT = 0;
UPDATE YOUR_TABLE_NAME SET AUTO_INCREMENT_COLUMN_NAME = @COUNT:=@COUNT+1;
하지만 해당 쿼리를 실행하는 동안 lock이 잡힐 것이라서 점검때에나 실행 할 수 있을 것이다.
이 방법 말고 직접 no를 세팅하는 방법도 있을 것 같다. 데이터 저장 시에 no값을 직접 할당해줄 수 있으니 말이다.
이번에 특정 서비스를 개발하다가 JSON 형태의 스트링을 그대로 데이터베이스에 저장했다가 꺼내써야하는 상황이 생겼다.
그것도 RBD인 mysql 데이터베이스에 말이다. mysql이 json 컬럼을 지원하기는 하지만 json 함수를 쿼리에 쓸 필요까지는 없는 상황이다보니 그냥 varchar타입으로 컬럼을 정의했고 여기에 json포맷의 스트링을 그대로 저장했다가 꺼내쓸 수 있도록 해야했다.
일단 타입핸들러를 이용하여 이를 처리하기로 했고 어떻게 사용하는 건지 검색을 좀 해봤다. 구글에서 "Type handler for ArrayList in myBatis" 라고 검색을 하니 많은 포스팅이 검색이 되었고 내 프로젝트에 맞게 가져다가 쓸 수 있었다.
작업 내용을 정리하자면 다음과 같다.
우선 dto의 구조를 보면 MyJsonDataWrapperClass 가 myJsonData 리스트를 들고 있는데 이때 MyJsonData 클래스가 바로 json 형태로 데이터베이스에 저장될 정보이다.
@Getter
@Setter
@JsonInclude(Include.NON_NULL)
public class MyJsonDataWrapperClass {
private Integer someIntData;
private List<MyJsonData> myJsonData = new ArrayList<>();
@Override
public String toString() {
return new Gson().toJson(this);
}
}
@Getter
@Setter
@EqualsAndHashCode
public class MyJsonData {
private String name;
private int age;
@Override
public String toString() {
return new Gson().toJson(this);
}
}
데이터베이스에는 myJsonData 라는 이름으로 text타입의 컬럼을 만들었고 mybatis의 insert문에 사용할 타입핸들러를 아래와 같이 명시해주었다.
@Slf4j
public class MyJsonDataTypeHandler extends BaseTypeHandler<List<MyJsonData>> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<MyJsonData> parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, new Gson().toJson(parameter));
}
@Override
public List<MyJsonData> getNullableResult(ResultSet rs, String columnName) throws SQLException {
return convertToList(rs.getString(columnName));
}
@Override
public List<MyJsonData> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return convertToList(rs.getString(columnIndex));
}
@Override
public List<MyJsonData> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return convertToList(cs.getString(columnIndex));
}
private List<MyJsonData> convertToList(String myJsonDataListAsString) {
try {
return new ObjectMapper().readValue(myJsonDataListAsString, new TypeReference<List<MyJsonData>>() {
});
} catch (IOException e) {
log.error("MyJsonDataTypeHandler failed to convert text to list, myJsonDataListAsString:{}", myJsonDataListAsString, e);
}
return Collections.emptyList();
}
}
BaseTypeHandler를 상속하면서 List<MyJsonData> 타입에 대한 핸들러임을 명시해주었고 setter에는 리스트 형태를 json 스트링으로 만들어주도록 Gson.toJson() 메서드를 이용하였고, getter에는 조회한 json 스트링을 다시 리스트 형태로 변환해주도록 하였는데 이때는 ObjectMapper.readValue()를 이용하였다.
조회시에는 mybatis 매핑파일에 아래와 같이 resultMap을 정의하여 MyJsonDataTypeHandler를 이용해서 myJsonData필드의 값을 ArrayList로 변환하도록 하였으며, 이렇게 정의한 resultMap을 select 문의 resultMap으로 선언해주었다.