1. MyBatis

  • MyBatis: Sql Mapping Framework라고 표현됨, Sql Mapping이란 SQL의 실행 결과를 객체 지향으로 매핑해준다는 뜻
  • MyBatis 사용 시, 기존 SQL을 그대로 사용할 수 있고 다음의 장점이 존재
    • PreparedStatement / ResultSet의 처리: 기존 프로그램 작성 시 하나씩 처리해야 하는 파라미터나 ResultSet의 getXXX()를 MyBatis가 알아서 처리해 주어 많은 양의 코드를 줄일 수 있음
    • Connection / PreparedStatement / ResultSet의 close() 처리: MyBatis와 스프링을 연동해서 사용하는 방식을 이용하면 자동으로 close() 처리 가능
    • SQL의 분리: MyBatis를 사용하면 별도의 파일이나 어노테이션 등을 이용해서 SQL을 선언, 파일을 이용하는 경우 SQL을 별도의 파일로 분리해서 운영 가능

 

 1) MyBatis와 스프링의 연동 방식

  • MyBatis는 단독으로 실행이 가능하지만, 스프링 프레임워크는 MyBatis와 연동을 쉽게 처리할 수 있는 라이브러리와 API를 제공
    • MyBatis를 단독으로 개발하고 스프링에서 DAO를 작성해서 처리하는 방식
      • 기존 DAO에서 SQL 처리를 MyBatis를 이용하는 구조
      • 완전히 MyBatis와 스프링 프레임워크를 독립적인 존재로 바라보고 개발
    • MyBatis와 스프링을 연동하고 Mapper 인터페이스만 이용하는 방식
      • 스프링과 MyBatis 사이에 'mybatis-spring'이라는 라이브러리 이용
      • 스프링이 데이터베이스 전체를 처리
      • MyBatis는 일부 기능 개발에만 활용하는 방식
      • 개발 시에는 Mapper 인터페이스라는 방식을 이용해서 인터페이스만으로 모든 개발이 가능한 방식

 

  • MyBatis를 위한 라이브러리들
    • 스프링 관련: spring-jdbc, spring-tx
    • MyBatis 관련: mybatis, mybatis-spring
dependencies {
    
    ...

    // spring 관련 라이브러리
    implementation group: 'org.springframework', name: 'spring-jdbc', version: '5.3.19'
    implementation group: 'org.springframework', name: 'spring-tx', version: '5.3.19'

    // MyBatis 관련 라이브러리
    implementation group: 'org.mybatis', name: 'mybatis', version: '3.5.6'
    implementation group: 'org.mybatis', name: 'mybatis-spring', version: '2.0.6'
}

 

  • MyBatis를 위한 스프링의 설정 - SqlSessionFactory
    • MyBatis를 이용하기 위해 스프링에서 설정해둔 HikariDataSource를 이용해서 SqlSessionFactory라는 Bean을 설정
    •  
<!-- root-context.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context = "http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd http://mybatis.org/schema/mybatis-spring http://mybatis.org/schema/mybatis-spring.xsd">

    <context:component-scan base-package = "org.zerock.springex.sample"></context:component-scan>

    ...

    <!-- 이 부분 추가 -->
    <bean id = "sqlSessionFactory" class = "org.mybatis.spring.SqlSessionFactoryBean">
        <property name = "dataSource" ref = "dataSource" />
    </bean>
    <!-- 여기까지 -->
    
</beans>

 


실습

 1) Mapper 인터페이스 활용하기

  - MyBatis는 SQL 파일을 별도로 처리할 수 있지만, 인터페이스와 어노테이션만으로도 처리 가능

  - 프로젝트에 mapper라는 패키지 구성 > 현재 시간을 처리하는 TimeMapper 인터페이스 선언

// TimeMapper
package org.zerock.springex.mapper;

import org.apache.ibatis.annotations.Select;

public interface TimeMapper {

  @Select("select now()")
  String getTime();
  
}

  - 데이터베이스의 현재 시각을 문자열로 처리하도록 구성

  - @Select 어노테이션으로 쿼리 작성 가능(';' 사용 x)

  - 작성된 인터페이스를 매퍼 인터페이스라고 하며, root-context.xml에 어떤 매퍼 인터페이스 설정했는지 등록해야함

<!-- root-context.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans ...

       xmlns:mybatis = "http://mybatis.org/schema/mybatis-spring"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

   ...

    <mybatis:scan base-package = "org.zerock.springex.mapper"></mybatis:scan>
</beans>

 

  - 테스트 코드를 통해 확인

  - Test > java > org.zerock.springex > mapper 패키지 > TimeMapperTests

// TimeMapperTests
package org.zerock.springex.sample.mapper;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.zerock.springex.mapper.TimeMapper;

@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class TimeMapperTests {

  // required가 fales이면 해당 객체를 주입 받지 못하더라도 예외가 발생 x
  @Autowired(required = false)
  private TimeMapper timeMapper;

  @Test
  public void testGetTime() {
    log.info(timeMapper.getTime());
  }
}

정상적으로 현재 시간이 출력됨

  - MyBatis와 스프링을 연동하고 매퍼 인터페이스를 활용하는 방식은 개발자가 실제 동작하는 클래스와 객체를 생성하지 않고, 스프링에서 자동 생성되는 방식 이용

  - 스프링에서 자동 생성되어 개발자가 직접 코드를 수정할 수 없다는 단점이 있지만, 인터페이스만으로 개발을 완료할 수 있다는 장점

 

 2) XML로 SQL 분리하기

  - SQL을 @Select 어노테이션으로 써도 되지만, SQL이 길어지면 처리가 복잡해지고, 어노테이션이 변경되면 프로젝트 전체를 다시 빌드하는 작업이 필요하기 때문에 단순 파일(XML)로 사용하는 것이 편리

  - XML과 매퍼 인터페이스 결합 과정

  • 매퍼 인터페이스 정의하고 메서드 선언
  • 해당 XML 파일 작성(파일이름과 매퍼 인터페이스 이름 같게), <select>와 같은 태그 이용해서 SQL 작성
  • <select>, <insert> 등 태그에 id 속성 값을 매퍼 인터페이스의 메서드 이름과 같에 작성

 

  - main > org.zerock.springex > mapper > TimeMapper2 파일 생성

// TimeMapper2
package org.zerock.springex.mapper;

public interface TimeMapper2 {

  String getNow();

}

  - main > source > mappers 패키지 생성 > TimeMapper2.xml 파일 생성(파일 이름을 매퍼 인터페이스와 같게)

<!-- TimeMapper2.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TimeMapper2">

    <!-- select의 id 속성값을 매터 인터페이스의 메서드 이름과 같게 설정 -->
    <!-- select 태그는 반드시 resultType이나 resultMap 속성을 지정해야 함 -->
    <!-- resultType은 select문이 결과를 어떤 타입으로 처리할 지에 대한 설정 -->
    <select id = "getNow" resultType = "string">
        select now()
    </select>

</mapper>

  - 마지막으로 root-context.xml 파일의 MyBatis 설정에 XML 파일들을 인식하도록 설정을 추가

<!-- root-context.xml -->
...

<bean id = "sqlSessionFactory" class = "org.mybatis.spring.SqlSessionFactoryBean">
        <property name = "dataSource" ref = "dataSource" />
        <!-- mapperLocations는 XML 매퍼 파일의 위치 -->
        <property name = "mapperLocations" value = "classpath:/mappers/**/*.xml"></property>
    </bean>
    
...

 

  - 테스트 코드로 정상적으로 동작하는지 확인

// TimeMapperTests
package org.zerock.springex.sample.mapper;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.zerock.springex.mapper.TimeMapper2;

@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class TimeMapperTests {

  @Autowired(required = false)
  private TimeMapper2 timeMapper2;

  @Test
  public void testNow() {
    log.info(timeMapper2.getNow());
  }
}

정상적으로 현재 시간이 출력됨

1. 스프링

 1) 의존성 주입

  • 어떻게 하면 객체와 객체 간의 관계를 더 유연하게 유지할 것인가(객체의 생성과 관계를 효과적으로 분리할 수 있는가)
  • 예를 들어, Todo 웹 애플리케이션을 만들 때, 모든 Controller는 TodoService 혹은 MemberService같은 서비스 객체 이용 → Controller는 서비스 객체에 의존적이다
  • 즉, 의존성이란 하나의 객체가 자신이 해야하는 일을 하기 위해 다른 개체의 도움이 필수적인 관계
  • 의존성을 해결하기 위해 스프링 프레임워크 사용

 2) 스프링 라이브러리 추가

  • maven spring 검색하여 Spring Core 라이브러리를 찾아, Gradle 메뉴의 코드 복사

 

  • 프로젝트의 build gradle 파일에 해당 코드 복사, spring-core에 더해서 spring-context, spring-test도 추가
  • lombok 라이브러리, Log4j2 라이브러리, JSTL 라이브러리 추가
dependencies {
    compileOnly('javax.servlet:javax.servlet-api:4.0.1')

    testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junitVersion}")

    // spring 관련 라이브러리
    implementation group: 'org.springframework', name: 'spring-core', version: '5.3.20'
    implementation group: 'org.springframework', name: 'spring-context', version: '5.3.20'
    implementation group: 'org.springframework', name: 'spring-test', version: '5.3.20'

    // lombok 라이브러리
    compileOnly 'org.projectlombok:lombok:1.18.24'
    annotationProcessor 'org.projectlombok:lombok:1.18.24'
    testCompileOnly 'org.projectlombok:lombok:1.18.24'
    testAnnotationProcessor 'org.projectlombok:lombok:1.18.24'

    // Log4j2 라이브러리
    implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.17.2'
    implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.17.2'
    implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.17.2'

    // JSTL 라이브러리
    implementation group: 'jstl', name: 'jstl', version: '1.2'
}

 

  • main > resource 폴더 > log4j2.xml 추가
<?xml version="1.0" encoding="UTF-8" ?>
<Configuration status = "INFO">
    <Appenders>
        <!-- 콘솔 -->
        <Console name = "Console" target = "SYSTEM_OUT">
            <PatternLayout charset = "UTF-8" pattern = "%d{HH:mm:ss.SSS} %5p [%c] %m%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <logger name = "org.springframework" level = "INFO" additivity = 'false'>
            <appender-ref ref = "console" />
        </logger>
        <logger name = "org.zerock" level = "INFO" additivity = "false">
            <appender-ref ref = "console" />
        </logger>
        <root level = "INFO" additivity = "false">
            <AppenderRef ref = "console" />
        </root>
    </Loggers>
</Configuration>

 


실습

 1) 의존성 주입하기

  - 프로젝트에 sample 패키지 > SampleService와 SampleDAO 클래스 생성

  - 스프링 프레임워크는 자체적으로 객체를 생성하고 관리하면서 필요한 곳에 객체를 주입시키는 역할(설정파일이나 어노테이션 이용)

  - 스프링이 관리하는 객체들은 빈(Bean)이라는 이름으로 불림, 프로젝트 내에 어떤 빈들을 어떻게 관리할 것인지 설정하는 설정 파일 작성

  - XML 설정이나 별도의 클래스를 이용한 자바 설정이 가능

  - XML 설정을 위해 WEB-INF 폴더 > New > XML Configuration File > Spring config > root-context.xml 파일 생성

  - Configuration application context 설정에서 인텔리제이가 현재 프로젝트를 스프링 프레임워크로 인식하고 필요한 기능을 지원하도록 설정

  - root-context.xml 내부에 <bean>이라는 태그를 이용해서 SampleService와 SampleDAO를 다음과 같이 설정

 

  - 스프링의 빈 설정 테스트

  - test 폴더 > org.zerock.springex.sample 패키지 생성 > SampleTest 클래스 추가

package org.zerock.springex.sample;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@Log4j2
// ExtendWith은 JUnit5 버전에서 spring-test를 이용하기 위한 설정
// ContextConfiguration은 스프링의 설정 정보를 로딩하기 위해 사용
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class SampleTest {

  // SampleService를 멤버 변수로 선언
  // Autowired는 스프링에서 의존성 주입 관련 어노테이션(만약 해당 타입의 빈이 존재하면 여기에 주입하기를 원한다)
  @Autowired
  private SampleService sampleService;

  @Test
  public void testService1() {
    log.info(sampleService);
    Assertions.assertNotNull(sampleService);
  }
}

테스트 성공 시 메세지(스프링이 생성하고 관리하고 있는 객체를 확인할 수 있음)


 

2. ApplicationContext와 빈(Bean)

  • Servlet이 Servlet Context 안에 존재한 것처럼, 스프링은 Bean을 관리하기 위해 ApplicationContext라는 존재 활용
  • root-context.xml에서 SampleService와 SampleDAO를 <bean>으로 설정하여 다음과 같이 저장됨

 

 1) @Autowired의 의미와 필드 주입

  • Test 코드에서 SampleService 타비의 변수가 선언될 때, @Autowired로 처리됨
  • Test 실행 시 @Autowired가 처리된 부분에 맞는 타입의 Bean이 존재하는지 확인하고 Test 실행시 주입
  • 멤버 변수에 직접 @Autowired를 선언하는 방식을 '필드 주입'이라고 함

 


실습

 2) SampleDAO 주입하기

  - @Autowired를 이용하면 필요한 타입을 주입받을 수 있다는 사실을 이용, SampleService에 SampleDAO를 주입

// SampleService
package org.zerock.springex.sample;

import lombok.ToString;
import org.springframework.beans.factory.annotation.Autowired;

@ToString
public class SampleService {
  @Autowired
  private SampleDAO sampleDAO;
}

  - Test 실행 시 SampleService안에 sampleDAO 객체가 주입된 것을 확인

SampleDAO의 의존성이 주입된 SampleService


 

 2) <context:component-scan>

  • 스프링 이용시, 클래스를 작성하거나 객체를 직접 생성하는 역할은 스프링 내부에서 이루어지며 applicationContext가 생성된 객체들을 관리하게 됨

 


실습

 2) @Service, @Repository

  - 스프링 프레임워크 사용을 위한 어노테이션

  • @Controller: MVC의 Controller를 위한 어노테이션
  • @Service: 서비스 계층의 객체를 위한 어노테이션
  • @Repository: DAO와 같은 객체를 위한 어노테이션
  • @Component: 일반 객체나 유틸리티 객체를 위한 어노테이션

  - 웹 영역 뿐 아닌 애플리케이션 전체에 사용할 수 있는 객체들을 전부 포함

  - 어노테이션을 이용하면 스프링 설정은 '해당 패키지를 조사해서 클래스의 어노테이션들을 이용'하는 설정으로 변경됨

  - root-context.xml의 설정을 다음과 같이 변경

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context = "http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
    
    <!-- component-scan의 속성값으로 패키지를 지정 -->
    <!-- 해당 패키지를 스캔해서 스프링의 어노테이션들을 인식함 -->
    <context:component-scan base-package = "org.zerock.springex.sample"/>
</beans>

 

  - SampleDAO는 해당 클래스의 객체가 스프링의 Bean으로 관리될 수 있도록 @Repository 어노테이션 추가

// SampleDAO
package org.zerock.springex.sample;

import org.springframework.stereotype.Repository;

@Repository
public class SampleDAO {
}

 

  - SampleService는 @Service 어노테이션 추가

// SampleService
package org.zerock.springex.sample;

import lombok.ToString;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
@ToString
public class SampleService {
  @Autowired
  private SampleDAO sampleDAO;
}

테스트 코드 정상 작동


 

 3) 생성자 주입 방식

  • 초기 스프링에서는 @Autowired를 멤버 변수에 할당하거나 Setter를 작성하는 방식을 많이 이용
  • 스프링3 이후, 생성자 주입 방식 이용
  • 생성자 주입 방식 규칙
    • 주입 받아야 하는 객체의 변수는 final로 작성
    • 생성자를 이용해서 해당 변수를 생성자의 파라미터로 지정
  • 생성자 주입 방식은 객체를 생성 시 문제가 발생하는지 미리 확인할 수 있어, 필드 주입이나 Setter 주입 방식보다 선호
  • Lombok에서 @RequiredArgsConstructor를 이용해 필요한 생성자 함수를 자동으로 작성 가능
  • SampleService를 다음과 같이 변경하면 @Autowired로 필드 주입한 것과 같이 SampleDAO를 주입 가능
// SampleService
package org.zerock.springex.sample;

import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
@ToString
@RequiredArgsConstructor
public class SampleService {

  private final SampleDAO sampleDAO;
}

 

 

3. 인터페이스를 이용한 느슨한 결합

  • 스프링이 의존성 주입을 가능하게 하지만 더 근본적으로 유연한 프로그램 설계를 위해 인터페이스를 이용
  • 인터페이스 이용 시, 나중에 다른 클래스의 객체로 쉽게 변경할 수 있도록 함
  • 앞에서 SampleDAO를 다른 객체로 변경하면 SampleService의 코드도 수정해야했지만, 인터페이스를 이용하면 실제 객체를 모르고 타입만을 이용해서 코드를 작성하는 일이 가능

 

 


실습

 4) SampleDAO를 인터페이스로 변경하기

  - 클래스로 작성된 SampleDAO를 인터페이스 타입으로 수정

// SampleDAO
package org.zerock.springex.sample;

import org.springframework.stereotype.Repository;

@Repository
public interface SampleDAO {
}

  - SampleDAO 인터페이스는 실체가 없기 때문에 SampleDAO 인터페이스를 구현한 클래스를 SampleDAOImpl이라는 이름으로 선언

// SampleDAOImpl
package org.zerock.springex.sample;

import org.springframework.stereotype.Repository;

// @Repository를 이용해서 해당 클래스의 객체를 스프링의 Bean으로 처리하도록 구성
@Repository
public class SampleDAOImpl implements SampleDAO{
}

 

  - SampleService 입장에서는 인터페이스만 바라보고 있기 때문에 실제 객체가 SampleDAOImpl의 인스턴스인지 알 수 없지만, 코드 작성에 문제 x

  - 느슨한 결합: 객체와 객체의 의존 관계의 실제 객체를 몰라도 가능하게 하는 방식

  - 느슨한 결합을 이용하면 나중에 SampleDAO 타입의 객체를 다른 객체로 변경해도 SampleService 타입을 이용하는 코드를 수정할 일이 없어 더 유연한 구조임

 

  - 다른 SampleDAO 객체로 변경해보기(특정 기간에만 SampleDAO를 다른 객체로 변경해야 하는 경우 생각)

  - EventSampleDAOImpl 클래스 작성

// EventSampleDAOImpl
package org.zerock.springex.sample;

import org.springframework.stereotype.Repository;

@Repository
public class EventSampleDAOImpl implements SampleDAO{
}

  - SampleService에 필요한 SampleDAO 타입의 Bean이 두 개(SampleDAOImpl, EventSampleDAOImpl)이므로, 어떤걸 주입해야 할 지 모르게 됨

  - Test 실행 시, 스프링이 SampleDAO 타입의 객체가 하나이길 기대했지만 2개가 발견됐다는 오류 출력

  - 해결 방법으로 두 클래스 중 하나를 @Primary라는 어노테이션으로 지정(지금 사용하고 싶은 것에 지정)

// EventSampleDAO
package org.zerock.springex.sample;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Repository;

@Repository
@Primary
public class EventSampleDAOImpl implements SampleDAO{
}

  - Test 실행 시, 정상적으로 EventSampleDAOImpl이 주입된 것을 확인

 

  - Qualifier 이용하기

  - @Primary 이용하는 방법 이외에 @Qualifier를 이용하여 이름을 지정해서 특정한 이름의 객체 주입

  - Lombok과 @Qualifier를 같이 이용하기 위해 src/main/java 폴더에 lombok.config 파일 생성

// lombok.config
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
// SampleDAOImpl
@Repository
// @Qualifier를 이용해서 SampleDAOImpl에 'normal'이라는 이름 지정
@Qualifier("normal")
public class SampleDAOImpl implements SampleDAO{
}


// EventSampleDAOImpl
@Repository
// @Qualifier를 이용해서 EventSampleDAOImpl에 'event'라는 이름 지정
@Qualifier("event")
public class EventSampleDAOImpl implements SampleDAO{
}

  - SampleService에서 @Qualifier를 이용해 이름을 지정하면 해당 이름의 SampleDAO 객체를 사용

// SampleService
package org.zerock.springex.sample;

import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

@Service
@ToString
@RequiredArgsConstructor
public class SampleService {

  @Qualifier("normal")
  private final SampleDAO sampleDAO;
}

SampleService에서 @Qualifier의 이름을 normal로 지정했을 때 SampleDAOImpl을 사용

// SampleService
package org.zerock.springex.sample;

import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

@Service
@ToString
@RequiredArgsConstructor
public class SampleService {

  @Qualifier("event")
  private final SampleDAO sampleDAO;
}

 

SampleService에서 @Qualifier의 이름을 event로 지정했을 때 EventSampleDAOImpl을 사용

 

  - 스프링의 Bean으로 지정되는 객체들

  - 스프링의 모든 클래스의 객체가 Bean으로 처리되는 것은 x

  - 스프링의 Bean으로 등록되는 객체들은 '핵심 배역'을 하는 객체(오랜 시간 프로그램에 상주하며 중요한 역할을 하는 '역할 중심' 객체

  - DTO나 VO 등 '역할'보다 '데이터' 중심으로 설계된 객체들은 스프링의 Bean으로 등록되지 않음(특히 DTO는 생명주기가 짧고 데이터 보관이 주된 역할이어서 Bean으로 처리 x)

 

  - XML이나 어노테이션으로 처리하는 객체

  - Bean으로 처리할 때 XML 설정을 이용할 수도 있고 어노테이션을 처리할 수도 있음

  - 판단 기준은 '코드를 수정할 수 있는가'

  - jar 파일로 추가되는 클래스의 객체를 Bean으로 처리해야 하면, 해당 코드가 존재하지 ㅇ낳아 어노테이션을 추가할 수 없어, XML에서 <bean>을 사용해 처리

  - 직접 작성되는 클래스는 어노테이션을 이용


 

4. 웹 프로젝트를 위한 스프링 준비

  • Bean을 담은 ApplicationContext가 웹 애플리케이션에서 동작하려면, 웹 애플리케이션이 실행될 때 스프링을 로딩해서 해당 웹 애플리케이션 내부에 스프링의 ApplicationContext를 생성하는 작업 필요
  • web.xml을 이용해서 리스너를 설정
  • web.xml 설정 이전에, 스프링 프레임워크의 웹 관련 작업은 spring-webmvc 라이브러리를 추가해야 설정 가능
// build.gradle 파일에 spring-webmvc 라이브러리 추가
dependencies {

    ...

    implementation group: 'org.springframework', name: 'spring-core', version: '5.3.20'
    implementation group: 'org.springframework', name: 'spring-context', version: '5.3.20'
    implementation group: 'org.springframework', name: 'spring-test', version: '5.3.20'
    implementation group: 'org.springframework', name: 'spring-webmvc', version: '5.3.20'

    ...


}
  • web.xml에 <listener> 설정과 이에 필요한 <context-param> 추가
<!-- web.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/root-context.xml</param-value>
    </context-param>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
</web-app>
  • 위의 설정을 한 뒤에 톰캣 실행 시, 스프링 관련 로그가 기록되며 실행

 


실습

 5) DataSource 구성

  - 톰캣과 스프링이 연동되는 구조를 완성하면, 웹 애플리케이션에서 필수인 데이터베이스 관련 설정 추가 필요

  - build.gradle에 MariaDB 드라이버와 HikariCP 관련 라이브러리 추가

// build.gradle
dependencies {

    ...

    implementation 'org.mariadb.jdbc:mariadb-java-client:3.0.4'
    implementation group: 'com.zaxxer', name: 'HikariCP', version: '5.0.1'
}

 

  - root-context.xml에 HikariCP 설정하기

  - 스프링에서는 HikariCP 사용을 위해 HikariConfig 객체와 HikariDataSource 초기화 설정을 Bean으로 처리(이전에는 ConnectUtil 클래스 사용)

<!-- root-context.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context = "http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

    <!-- component-scan의 속성값으로 패키지를 지정 -->
    <!-- 해당 패키지를 스캔해서 스프링의 어노테이션들을 인식함 -->
    <context:component-scan base-package = "org.zerock.springex.sample"></context:component-scan>

    <!-- db와 connection pool 형성을 위한 hikariCP를 구성 -->
    <!-- hikariConfig를 이용해서 HikariDataSource를 구성 -->
    <bean id = "hikariConfig" class = "com.zaxxer.hikari.HikariConfig">
        <property name = "driverClassName" value = "org.mariadb.jdbc.Driver"></property>
        <property name = "jdbcUrl" value = "jdbc:mariadb://localhost:3306/webdb"></property>
        <property name = "username" value = "webuser"></property>
        <property name = "password" value = "비밀번호"></property>
        <property name = "dataSourceProperties">
            <props>
                <!-- cache 사용 여부에 대한 설정 -->
                <prop key = "cachePrepStmts">true</prop>
                <!-- 서버 연결 당 cache할 statement의 수에 관한 설정(기본값 25, 권장 250~500 -->
                <prop key = "prepStmtCacheSize">250</prop>
                <!-- 드라이버가 cache할 SQL문의 최대 길이(기본값 256, 권장 2048 -->
                <prop key = "prepStmtCacheSqlLimit">2048</prop>
            </props>
        </property>
    </bean>

    <!-- HikariDataSource는 <constructor-arg ref='hikariConfig" />로 id값을 참조해서 사용 -->
    <bean id = "dataSource" class = "com.zaxxer.hikari.HikariDataSource" destroy-method = "close">
        <constructor-arg ref = "hikariConfig" />
    </bean>
</beans>

 

  - SampleTest에는 root-context.xml에 선언된 HikariCPp를 주입받기 위해 DataSource 타입의 변수를 선언, @Autowired를 통해 주입 받음

  - testConnection()을 작성하여 데이터베이스와의 연결을 test

// SampleTest
package org.zerock.springex.sample;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import javax.sql.DataSource;
import java.sql.Connection;

@Log4j2
// ExtendWith은 JUnit5 버전에서 spring-test를 이용하기 위한 설정
// ContextConfiguration은 스프링의 설정 정보를 로딩하기 위해 사용
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class SampleTest {

  ...

  @Autowired
  private DataSource dataSource;

  ...

  @Test
  public void testConnection() throws Exception {
    Connection connection = dataSource.getConnection();
    log.info(connection);
    Assertions.assertNotNull(connection);

    connection.close();
  }
}

testConnection의 결과, 데이터베이스와 정상적으로 연결되었다고 출력된 로그

  - 테스트에서 보이듯, 스프링은 필요한 객체를 스프링에 주입해 주기 때문에 개별적으로 클래스를 작성하여 Bean으로 등록해두면 원하는 곳에서 쉽게 다른 객체 사용 가능

1. 리스너의 개념과 용도

  • 옵저버 패턴: 특정한 변화를 구독(subscribe)하는 객체들을 보관하고 있다가 변화가 발생(public)하면 구독 객체들을 실행
  • Servlet API는 여러 이벤트(Event)에 맞는 리스너들을 인터페이스로 정의해두었고 이를 통해 다음 작업 가능
    • 해당 웹 애플리케이션이 시작되거나 종료될 때 특정 작업 수행
    • HttpSession에 특정 작업에 대한 감시와 처리
    • HttpServletRequest에 특정 작업에 대한 감시와 처리

 


실습

 1) ServletContextListener

  - 해당 프로젝트가 실행되자 마자 실행시키고 싶은 작업

  - 프로젝트 내에 listener 패키지 생성 > 패키지 내에 W2AppListener 클래스 생성

// W2AppListener
package org.zerock.w2.listener;

import lombok.extern.log4j.Log4j2;

import javax.servlet.ServletContextEvent
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;

@WebListener
@Log4j2
public class W2AppListener implements ServletContextListener {
  
  // 시작할 때
  @Override
  public void contextInitialized(ServletContextEvent sce) {
    log.info("-----------------init-----------------");
    log.info("-----------------init-----------------");
    log.info("-----------------init-----------------");
  }
  
  // 끝낼 때
  @Override
  public void contextDestroyed(ServletContextEvent sce) {
    log.info("----------------destroy----------------");
    log.info("----------------destroy----------------");
    log.info("----------------destroy----------------");
  }
}

시작할 때 메세지 출력
끝낼 때 메세지 출력


 

 

  • ServletContextEvent와 ServletContext
    • contextInitialized()와 contextDestroyed()에는 파라미터로 ServletContextEvent라는 객체가 전달됨
    • ServletContextEvent를 이용하면 현재 애플리케이션이 실행되는 공간인 ServletContext에 접근 가능
    • ServletContext는 현재 웹 애플레케이션 내 모든 자원들을 같이 사용하는 공간
    • 이 공간에 무언가를 저장하면 모든 Controller나 JSP 등에서 이를 활용 가능
    • ServletContext에는 setAttribute()를 이용해서 원하는 이름으로 객체를 보관할 수 있음
    • 다음과 같이 'appName'이라는 이름으로 'W2'라는 이름을 지정하면, EL에서 ${appName}으로 이를 이용 가능

// W2AppListener에서 setAttribute()를 통해 객체를 생성
package org.zerock.w2.listener;

import lombok.extern.log4j.Log4j2;

import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;

@WebListener
@Log4j2
public class W2AppListener implements ServletContextListener {

  @Override
  public void contextInitialized(ServletContextEvent sce) {
    log.info("-----------------init-----------------");
    log.info("-----------------init-----------------");
    log.info("-----------------init-----------------");

    // 시작과 함께 객체 생성
    ServletContext servletContext = sce.getServletContext();
    servletContext.setAttribute("appName", "W2");
  }

  ...
  
}
// TodoListController에서 다음과 같이 객체 활용 가능
package org.zerock.w2.controller;

...

@WebServlet(name = "todoListController", value = "/todo/list")
@Log4j2
public class TodoListController extends HttpServlet {
  private TodoService todoService = TodoService.INSTANCE;

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("todo list.................");

    // getServletContext() 메서드를 이용해 ServletContext를 이용할 수 있음
    ServletContext servletContext = req.getServletContext();
    log.info("appName: " + servletContext.getAttribute("appName"));
    
    ...
    
  }
}
<!-- list.jsp에 다음과 같이 EL 구문을 추가하면 화면에서 나타내는 용도로 사용가능 -->
<body>
<h1>Todo List</h1>
<!-- appName이라는 이름을 가진 객체(W2)를 호출 -->
<h2>${appName}</h2>
<h2>${loginInfo}</h2>
<h3>${loginInfo.mname}</h3>

...

</body>

${appName}을 입력하여, appName을 이름으로 하는 객체 "W2"를 출력

 

  • ServletContextListener와 스프링 프레임워크
    • ServletContextListener와 ServletContext를 이용하면 프로젝트 실행 시 필요한 객체들을 준비하는 작업 처리 가능
    • 커넥션 풀 초기화 또는 TodoService 같은 객체 미리 생성해서 보관 등
    • 특히, 스프링 프레임워크에서 웹 프로젝트를 미리 로딩하는 작업을 처리할 때 ServletContextListener 이용

 


실습

 2) 세션 관리 리스너

  - Servlet의 리스너 중 HttpSession 관련 작업을 감시하는 리스너 등록 가능

              (HttpSessionListener나 HttpSessionAttributeListener 등)

  - 이를 이용해서 HttpSession이 생성되거나 setAttribute() 등의 작업이 이루어질 때를 감지 가능

  - listener 패키지에 LoginListener 클래스 추가

// LoginListener
package org.zerock.w2.listener;

import lombok.extern.log4j.Log4j2;

import javax.servlet.annotation.WebListener;
import javax.servlet.http.HttpSessionAttributeListener;
import javax.servlet.http.HttpSessionBindingEvent;

@WebListener
@Log4j2
public class LoginListener implements HttpSessionAttributeListener {

  @Override
  public void attributeAdded(HttpSessionBindingEvent event) {
    // 이름과 객체의 값들을 받아옴
    String name = event.getName();
    Object obj = event.getValue();

    if(name.equals("loginIndo")) {
      log.info("A user logined.........");
      log.info(obj);
    }
  }
}

로그인 실행 시 로그에 위의 메세지가 출력됨

  - LoginListener는 HttpSessionAttributeListener 인터페이스를 구현

  - HttpSessionAttributeListener 인터페이스는 attributeAdded(), attributeRemoved(), attributeReplaced() 를 이용해서, HttpSession에 setAttribute() / removeAttribute() 등의 작업을 감지

1. 쿠키의 생성 / 전송

  • 서버에서 자동 생성하는 쿠키(JSESSIONID)와 사용자가 정의하는 쿠키의 다른 점
  사용자 정의 쿠키 WAS에서 발행하는 쿠키(JSESSIONID)
생성 개발자가 직접 newCookie()로 생성 경로도 지정 가능 자동
전송 반드시 HttpServletResponse에 addCookie()를 통해야 전송  
유효기간 쿠키 생성 시 초 단위로 지정 가능 지정불가
브라우저의 보관방식 유효기간이 없는 경우 메모리상에만 보관
유효기간이 있는 경우 파일이나 기타 방식으로 보관
메모리상에만 보관
쿠키의 크기 4kb 4kb
  • 개발자가 newCookie()를 이용해 쿠키를 생성할 때는 문자열로 된 이름과 값이 필요
    이때, 값은 일반적인 문자열로 저장이 불가능 하여 URLEncoding된 문자열로 저장해야함(한글 불가능)

 

 1) 쿠키를 사용하는 경우

  • 쿠키는 서버와 브라우저 사이를 오가기 때문에 보안에 취약한 단점(이 때문에 쿠키의 용도는 제한적)
  • 오랜 기간 보관해야하는 데이터는 서버에, 약간의 편의 제공을 위한 데이터는 쿠키로 보관
    (오늘 하루 이 창 열지 않기 또는 최근 본 상품 목록 등은 쿠키를 이용)
  • 쿠키가 가장 잘 쓰이는 곳은 자동 로그인(쿠키의 유효기간이 지정되면 브라우저가 종료되어도 보관되는 방식)

 


실습

 1) 조회한 Todo 확인하기

  - Todo 목록에서 조회했던 Todo 번호(tno) 쿠키들을 이용해서 보관

  - 동작 방식

  • 브라우저에서 전송된 쿠키가 있는 지 확인, 있다면 해당 쿠키 값 활용, 없다면 새로운 문자열 생성
  • 쿠키의 이름은 'viewTodos'
  • 문자열 내에 현재 Todo의 번호를 문자열로 연결
  • '2-3-4-'와 같은 형태로 연결, 이미 조회한 번호는 추가 x
  • 쿠키의 유효기간으 24시간으로 하고, 쿠키를 담아서 전송

  - TodoController에 추가할 코드

  • 현재 요청에 있는모든 쿠키 중 조회 목록 쿠키(viewTodos)를 찾아내는 메서드
  • 특정 tno가 쿠키의 내용물이 있는지 확인하는 코드
// TodoReadController
package org.zerock.w2.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.w2.dto.TodoDTO;
import org.zerock.w2.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet(name = "todoReadController", value = "/todo/read")
@Log4j2
public class TodoReadController extends HttpServlet {
  private TodoService todoService = TodoService.INSTANCE;

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    try {
    
      ...

      // 쿠키 찾기
      Cookie viewTodoCookie = findCookie(req.getCookies(), "viewTodos");
      String todoListStr = viewTodoCookie.getValue();
      boolean exist = false;

      if(todoListStr != null && todoListStr.indexOf(tno+"-") >= 0) {
        exist = true;
      }

      log.info("exist: " + exist);

      // 조회했던 번호(tno)를 계속 추가
      if(!exist) {
        todoListStr += tno + "-";
        viewTodoCookie.setValue(todoListStr);
        viewTodoCookie.setMaxAge(60*60*24);
        viewTodoCookie.setPath("/");
        resp.addCookie(viewTodoCookie);
      }

      ...
  
  // 쿠키가 쿠키 목록에 존재하는지 찾아보는 함수
  private Cookie findCookie(Cookie[] cookies, String cookieName) {
    Cookie targetCookie = null;

    // 존재하는 쿠키 목록의 쿠키들을 새로운 쿠키와 비교, 같은 이름을 가진 쿠키가 있다면 그 쿠키로 변경
    if(cookies != null && cookies.length > 0) {
      for(Cookie ck:cookies) {
        if(ck.getName().equals(cookieName)) {
          targetCookie = ck;
          break;
        }
      }
    }

    // 쿠키 목록에 쿠키가 없다면 새로운 쿠키 생성
    if(targetCookie == null) {
      targetCookie = new Cookie(cookieName, "");
      targetCookie.setPath("/");
      targetCookie.setMaxAge(60*60*24);
    }

    return targetCookie;
  }
}

 

2. 쿠키와 세션을 같이 활용하기

  • 작성된 코드는 '/todo/...'로 시작하는 모든 경로에 로그인이 필요하기 때문에 매번 로그인해야 하는 불편함 존재
  • 자동 로그인은 로그인한 사용자의 정보를 쿠키에 보관하고 이를 이용해서 사용자의 정보를 HttpSession에 담는 방식

 

 1) 자동 로그인 준비

  • 쿠키에 어떤 값을 보관하게 할 것인지 결정, 유효시간도 고려
  • 로그인 구현 방식
    • 사용자가 로그인할 때 임의의 문자열 생성하고 이를 데이터베이스에 보관
    • 쿠키에는 생성된 문자열을 값으로 삼고 유효기간은 일주일로 지정
  • 로그인 체크 구현 방식
    • 현재 사용자의 HttpSession에 로그인 정보가 없는 경우에만 쿠키 확인
    • 쿠키의 값과 데이터베이스의 값을 비교하고, 같다면 사용자의 정보를 읽어와서 HttpSession에 사용자 정보 추가
  • 구현을 위해 tbl_member 테이블에 임의의 문자열을 저장할 uuid 칼럼 추가
// database console_
alter table tbl_member add column uuid varchar(50);

 


실습

 2) 자동 로그인 처리

  - login.jsp에 자동 로그인 여부를 묻는 체크박스를 추가

// login.jsp

...

<form action = "/login" method = "post">
  <input type = "text" name = "mid">
  <input type = "text" name = "mpw">
  <!-- 추가한 부분 -->
  <input type = "checkbox" name = "auto">
  <!-- 여기까지 -->
  <button type = "submit">LOGIN</button>
</form>

...

 

  - 로그인을 처리하는 LoginController의 doPost()에서 'auto'라는 이름으로 체크박스에서 전송되는 값이 'on'인지 확인

// loginController
package org.zerock.w2.controller;

import lombok.extern.java.Log;
import lombok.extern.log4j.Log4j2;
import org.zerock.w2.dto.MemberDTO;
import org.zerock.w2.service.MemberService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.UUID;

@WebServlet("/login")
@Log
public class LoginController extends HttpServlet  {

  ...
  
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("login post.............");

    String mid = req.getParameter("mid");
    String mpw = req.getParameter("mpw");

    String auto = req.getParameter("auto");
    boolean rememberMe = auto != null && auto.equals("on");
    // rememberMe라는 변수가 true(auto 값이 있고 그 값이 "on"이라면, 즉 자동 로그인이 설정되어 있다면)
    // UUID(java.util 중 임의의 숫자 생성하는 도구)를 이용해서 임의의 번호 생성
    if(rememberMe) {
      String uuid = UUID.randomUUID().toString();
    }

    ...
}

 

  - MemberVO, MemberDTO의 수정: uuid가 추가되었으므로 각각에 'private String uuid;' 추가

 

  - rememberMe가 true라면 tbl_member 테이블에 사용자의 정보에 uuid를 수정하도록 MemberDAO에 추가 기능 작성

// MemberDAO
package org.zerock.w2.dao;

import lombok.Cleanup;
import org.zerock.w2.domain.MemberVO;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class MemberDAO {

  ...

  public void updateUuid(String mid, String uuid) throws Exception {
    String sql = "update tbl_member set uuid = ? where mid = ?";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    preparedStatement.setString(1, uuid);
    preparedStatement.setString(2, mid);
    preparedStatement.executeUpdate();
  }
}

  - MemberService에도 메서드 추가

// MemberService
package org.zerock.w2.service;

import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.zerock.w2.dao.MemberDAO;
import org.zerock.w2.domain.MemberVO;
import org.zerock.w2.dto.MemberDTO;
import org.zerock.w2.util.MapperUtil;

@Log4j2
public enum MemberService {
  INSTANCE;

  ...
  
  public void updateUuid(String mid, String uuid) throws Exception {
    dao.updateUuid(mid, uuid);
  }
}

  - LoginController에서 위 과정을 로그인 후에 반영하도록 설정

// LoginController
package org.zerock.w2.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.w2.dto.MemberDTO;
import org.zerock.w2.service.MemberService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.UUID;

@WebServlet("/login")
@Log4j2
public class LoginController extends HttpServlet  {

  ...

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("login post.............");

    String mid = req.getParameter("mid");
    String mpw = req.getParameter("mpw");

    String auto = req.getParameter("auto");
    boolean rememberMe = auto != null && auto.equals("on");
    
    try {
      MemberDTO memberDTO = MemberService.INSTANCE.login(mid, mpw);

      // rememberMe라는 변수가 true(auto 값이 있고 그 값이 "on"이라면, 즉 자동 로그인이 설정되어 있다면)
      // UUID(java.util 중 임의의 숫자 생성하는 도구)를 이용해서 임의의 번호 생성
      if(rememberMe) {
        String uuid = UUID.randomUUID().toString();
        
        MemberService.INSTANCE.updateUuid(mid, uuid);
        memberDTO.setUuid(uuid);
      }
      
      // 정상적으로 로그인 된 경우, HttpSession을 이용해서 'loginInfo'라는 이름으로 객체 저장
      HttpSession session = req.getSession();
      session.setAttribute("loginInfo", memberDTO);
      resp.sendRedirect("/todo/list");
    } catch (Exception e) {   // 예외 발생 시 '/login'으로 이동, result라는 파라미터를 전달해서 문제가 발생했다는 사실 전달
      resp.sendRedirect("/login?result=error");
    }
  }
}

 

  - 결과

로그인 화면에 자동로그인을 설정할 체크박스 생성됨
체크 상태로 로그인하면 uuid가 새로 추가된 모습
데이터베이스의 테이블에도 추가된 uuid 값

 

  - 쿠키의 생성 및 전송: 쿠키에 들어가야 하는 문자열이 제대로 처리되었다면 브라우저에 'remember-me' 이름의 쿠키 생성해서 전송

// LoginController에서 rememberMe 변수가 있을 때 수행하는 코드 부분 변경
if(rememberMe) {
        String uuid = UUID.randomUUID().toString();

        MemberService.INSTANCE.updateUuid(mid, uuid);
        memberDTO.setUuid(uuid);

        Cookie rememberCookie = new Cookie("remember-me", uuid);
        rememberCookie.setMaxAge(60*60*24*7);
        rememberCookie.setPath("/");
        
        resp.addCookie(rememberCookie);
}

 

  - 결과

응답 헤더에 Set-Cookie에 remember-me가 전송됨
쿠키도 다음과 같이 확인 가능

 

  - 쿠키의 값을 이용한 사용자 조회

  - MemberDAO에 selectUUID() 기능 추가

// MemberDAO
public MemberVO selectUUID(String uuid) throws Exception {
    String query = "select mid, mpw, manme, uuid from tbl_member where uuid = ?";
    
    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(query);
    preparedStatement.setString(1, uuid);
    
    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();
    
    resultSet.next();
        
        MemberVO memberVO = MemberVO.builder()
            .mid(resultSet.getString(1))
            .mpw(resultSet.getString(2))
            .mname(resultSet.getString(3))
            .uuid(resultSet.getString(4))
            .build();
    return memberVO;
}

  - MemberService에서 uuid 값으로 사용자를 찾을 수 있도록 getByUUID() 추가

// MemberService
public MemberDTO getByUUID(String uuid) throws Exception {
  MemberVO vo = dao.selectUUID(uuid);
  MemberDTO memberDTO = modelMapper.map(vo, MemberDTO.class);
  return memberDTO;
}

 

  - LoginCheckFilter에서 쿠키 체크

  - 원래 HttpSession에 'loginInfo'라는 이름으로 객체가 저장되고 이 객체의 여부만 확인하면 되었지만,
       HttpSession에는 없고 쿠키에 UUID 값만 있는 경우 고려

  - 진행 과정

  • HttpServletRequest를 이용해서 몯느 쿠키 중 'remember-me' 이름의 쿠키 검색
  • 해당 쿠키의 value를 이용해서 MemberService를 통해 MemberDTO 구성
  • HttpSession을 이용해서 'loginInfo'라는 이름으로 MemberDTO를 setAttribute()
  • 정상적으로 FilterChain의 doFilter() 수행
// LoginCheckFilter
package org.zerock.w2.filter;

import lombok.extern.log4j.Log4j2;
import org.zerock.w2.dto.MemberDTO;
import org.zerock.w2.service.MemberService;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import java.util.Optional;

// '/todo..'로 시작하는 모든 경로에 필터링 시도
@WebFilter(urlPatterns = {"/todo/*"})
@Log4j2
public class LoginCheckFilter implements Filter {

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

    log.info("Login check filter....");

    // doFilter에서 HttpServletRequest와 HttpServletResponse보다 상위 타입의 파라미터 사용
    // 따라서 HTTP 관련 작업을 위해 (HttpServlerRequest)request처럼 다운캐스팅 필요
    HttpServletRequest req = (HttpServletRequest)request;
    HttpServletResponse resp = (HttpServletResponse)response;

    HttpSession session = req.getSession();

    // LoginController에서 로그인했을 때 세션에 loginInfo라는 이름으로 저장하도록 하였음
    // 이 loginInfo가 없다면 로그인하지 않은 것으로 판단되므로 LoginCheckFilter에 의해 '/login' 경로로 이동
    if (session.getAttribute("loginInfo") != null) {
      // 어떤 값이든 '/login'에서 로그인 정보를 전달해서 로그인이 처리되면 그 이후에는 '/todo/...' 경로 이용가능
      chain.doFilter(request, response);
      return;
    }

    // session에 loginInfo 값이 없다면, 쿠키를 체크
    Cookie cookie = findCookie(req.getCookies(), "remember-me");

    // session에도 없고 쿠키에도 없다면 그냥 로그인 화면으로 보내기
    if(cookie == null) {
      resp.sendRedirect("/login");
      return;
    }

    // 쿠키가 존재한다면
    log.info("cookie가 존재");
    // uuid 값
    String uuid = cookie.getValue();

    try {
      // 데이터베이스 확인
      MemberDTO memberDTO = MemberService.INSTANCE.getByUUID(uuid);

      log.info("쿠키의 값으로 조회한 사용자 정보: " + memberDTO);
      // 회원정보를 session에 추가
      session.setAttribute("loginInfo", memberDTO);
      chain.doFilter(request, response);
    } catch (Exception e) {
      e.printStackTrace();
      resp.sendRedirect("/login");
    }
  }

  private Cookie findCookie(Cookie[] cookies, String name) {
    if(cookies == null || cookies.length == 0) {
      return null;
    }

    Optional<Cookie> result = Arrays.stream(cookies)
        .filter(ck -> ck.getName().equals(name))
        .findFirst();

    return result.isPresent()?result.get():null;
  }
}

 

  - 결과

  - HttpSession 내에 loginInfo로 저장된 객체도 없고, remember-me 쿠키도 없는 상황 → '/login' 경로로 redirect

 

  - HttpSession에는 없지만 쿠키가 존재하는 경우 → 정상적으로 '/todo/...' 경로로 이동 가능

한번 로그인해서 remember-me 쿠키가 생김


 

  • 앞선 방식들의 단점
    • 쿠키가 가진 UUID 값에 어느 정도 갱신을 위한 추가 장치 필요(UUID 값을 알면 자동으로 로그인 가능)
    • 따라서 주기적으로 UUID 값을 바꾸는 방식을 적용해야 좀 더 안전한 자동 로그인 구현 가능

1. 무상태에서 과거를 기억하는 법

  • HTTP는 무상태이므로 과거 요청 기록을 알 수 없음

 

  • 세션 트랙킹: 과거 요청 기록을 추적하는 기법
  • HTTP는 세션 트랙킹에 쿠키를 이용

 

  • 쿠키: 문자열로 만들어진 데이터 조각
  • 쿠키는 서버와 브라우저 사이에서 요청이나 응답시에 주고받는 형태로 사용됨
  • 쿠키의 가장 기본적인 형태는 이름과 값으로 구성된 구조
  • 브라우저에서  개발자 도구의 애플리케이션 메뉴에서 확인 가능
  • 쿠키를 주고받는 기본적인 시나리오
    • 브라우저에서 최초로 서버 호출 시 해당 서버에서 발행한 쿠키가 없다면 브라우저는 아무것도 전송 x
    • 서버에서 응답 메세지 보낼 때, 브라우저에 쿠키를 보내는데 이때 'Set Cookie'라는 HTTP 헤더 이용
    • 브라우저는 쿠키를 받은 후 이에 대한 정보를 읽고, 파일 형태로 보관할 지 메모리상에서만 처리할 지 결정(쿠키에 있는 유효기간(만료기간)을 보고 결정)
    • 브라우저에 보관하는 쿠키는 다음에 다시 브라우저가 서버에 요청할 때 HTTP 헤더에 'Cookie'라는 헤더 이름과 함께 전달(쿠키에는 경로를 지정할 수 있어서 해당 경로에 맞는 쿠키 전송)
    • 서버에서는 필요에 따라서 브라우저가 보낸 쿠키를 읽고 사용

 

 1) 쿠키를 생성하는 방법

  • 서버에서 자동으로 생성하는 쿠키: 응답 메세지를 작성할 때 정해진 쿠키가 없는 경우 자동으로 발행(WAS에서 발행되며 WAS마다 고유한 이름 사용(톰캣은 JSESSIONID))
    • 서버에서 발행하는 쿠키는 기본적으로 브라우저 메모리 상에 보관, 브라우저 종료 시 쿠키는 삭제됨
    • 서버에서 발행하는 쿠키의 경로는 '/'
  • 개발자가 생성하는 쿠키: 개발자가 생성하는 쿠키는 서버에서 생성되는 쿠키와 다음이 다름
    • 이름 원하는대로 지정 가능
    • 유효기간 지정가능(유효기간 지정 시, 브라우저가 이를 파일 형태로 보관)
    • 반드시 직접 응답에 추가해 주어야 함
    • 경로나 도메인 등을 지정 가능(특정 서버의 경로를 호출하는 경우에만 쿠키 사용)

 

 

2. Servlet 컨텍스트와 세션 저장소

  • 하나의 톰캣은 여러 웹 애플리케이션을 실행할 수 있음
    • 실제 운영 시 웹 애플리케이션 마다 별도의 도메인으로 분리해서 운영됨
    • 프로젝트 실행 경로를 '/'외에 다른 이름으로 각각 지정해서 실행하면 하나의 톰캣에서 여러 애플리케이션 실행 가능
    • 각 애플리케이션은 자신만 사용하는 고유 메모리 영역을 생성하여 이 공간에 Servlet이나 JSP 등을 인스턴스로 만들어 서비스 제공, 이 영역을 Servlet API에서는 Servlet 컨텍스트라고 함
  • 애플리케이션 생성 시 톰캣이 발행하는 쿠키를 관리하기 위한 메모리 영역이 하나 더 생성됨(세션 저장소)
    • 세션 저장소는 기본적으로 키와 값을 보관하는 구조
    • 톰캣에서 JSESSIONID가 키가 됨
    • 새로운 JSESSIONID 쿠키가 만들어질 때마다 메모리 공간을 차지하므로 톰캣은 주기적으로 세션 저장소를 조사하며 사용하지 않는 값 정리(session-timeout 설정 이용, 기본 30분 주기마다 지정된 시간보다 오래된 값 삭제)

 

 1) 세션을 통한 상태 유지 메커니즘

  • HttpServletRequest의 getSession()이라는 메서드 실행
    → 톰캣에서는 JSESSIONID 이름의 쿠키가 요철할 때 있었는지 확인
    → 없다면 새로운 값을 만들어 세션 저장소에 보관

    • A1234와 B111은 해당 공간에 login 정보가 존재하며, 서버에서 프로그램 작성 시 이를 이용해서 해당 사용자가 로그인했다는 것을 인정하는 방식

 

 2) HttpServletRequest의 getSession()

  • HttpServletRequest의 getSession()은 브라우저가 보내는 정보를 이용해 다음의 작업 수행
    • JSESSIONID가 없는 경우: 세션 저장소에 새로운 번호로 공간을 만들고 해당 공간에 접근할 수 있는 객체 반환(새로운 번호는 브라우저에 JSESSIONID의 값으로 전송(세션 쿠키))
    • JSESSIONID가 있는 경우: 세션 저장소에 JSESSIONID 값을 이용해서 할당된 공간을 찾고 이 공간에 접근할 수 있는 객체 반환
  • getSession()의 결과물은 세션 저장소 내의 공간, 이 공간을 의미하는 타입은 HttpSession타입이며 해당 공간은 세션 컨텍스트 또는 세션이라고 함

  • HttpSession 타입의 객체를 이용하면 현재 사용자만의 공간에 원하는 객체를 저장 / 수정 / 삭제할 수 있음
  • isNew() 메서드 등으로 새롭게 공간을 만들 것인지 기존 공간 재사용할 것인지 구분 가능

 

 

3. 세션을 이용한 로그인 체크

  • 세션을 이용한 로그인 체크 시나리오
    • 사용자가 로그인에 성공하면 HttpSession을 이용해 해당 사용자의 공간(세션 컨텍스트)에 특정 객체를 이름과 함께 저장
    • 로그인 체크가 필요한 컨트롤러에서 현재 사용자의 공간에 지정된 이름으로 객체가 저장되었는지 확인
      → 객체가 존재한다면 해당 사용자는 로그인된 사용자로 간주
      → 아니면 로그인 페이지로 이동

 


실습

 1) 등록할 때, 로그인 체크 하기

  - 로그인한 사용자만 Todo를 등록할 수 있다고 가정

  - TodoRegisterController에서 doGet() 수정

// TodoRegisterController

...

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
log.info("/todo/register GET .......");

HttpSession session = req.getSession();

// 기존에 JSESSIONID가 없는 새로운 사용자
if (session.isNew()) {
  log.info("User with new JSESSIONID cookie");
  resp.sendRedirect("/login");
  return;
}

// JSESSIONID는 있지만 해당 세션 켄텍스트에 loginInfo라는 이름으로 저장된 객체가 없는 경우
if (session.getAttribute("loginInfo") == null) {
  log.info("User without login information");
  resp.sendRedirect("/login");
  return;
}

// 정상적인 경우
req.getRequestDispatcher("/WEB-INF/todo/register.jsp").forward(req, resp);
}

...

  - 브라우저는 '/login'으로 redirect 됨

  - HttpServletRequest의 getSession()을 호출하여 새로운 값이 생성되어 브라우저로 전송되어 저장

(JSESSIONID가 없는 채로 'todo/register' 경로에 접속했을 때, '/login'으로 redirect되고 '/login'에서 개발자 도구를 통해 register의 응답 헤더를 보면 'Set-Cookie' 헤더가 전송된 것을 확인할 수 있음 ↓)

  - 로그에는 '기존에 JSESSIONID가 없는 사용자'에 대한 처리를 했을 때 나오도록 설정한 메세지가 출력됨

 

 2) 로그인 처리 Controller 작성

  - 로그인은 '/login' 경로에서 GET방식으로 로그인 화면 보여주고, POST 방식으로 실제 로그인 처리하도록 구성

  - controller 패키지 내에 LoginController 클래스와 WEB-INF 폴더 내에 로그인 화면을 나타낼 login.jsp 파일 생성

// LoginController
package org.zerock.w2.controller;

import lombok.extern.java.Log;
import lombok.extern.log4j.Log4j2;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/login")
@Log
public class LoginController extends HttpServlet  {

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("login get.............");
    req.getRequestDispatcher("/WEB-INF/login.jsp").forward(req, resp);
  }
}
<!-- login.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>

<form action = "/login" method = "post">
  <input type = "text" name = "mid">
  <input type = "text" name = "mpw">
  <button type = "submit">LOGIN</button>
</form>

</body>
</html>

 

  - 로그인 처리와 HttpSession의 setAttribute()

  - LoginController에서 POST 방식으로 파라미터 수집, HttpSession에 'loginInfo' 이름을 이용해서 간단한 문자열을 저장하도록 구성

// LoginController
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
log.info("login post.............");

String mid = req.getParameter("mid");
String mpw = req.getParameter("mpw");

String str = mid + mpw;
HttpSession session = req.getSession();
// HttpSession을 이용하여 setAttribute() 메서드를 사용자 공간에 loginInfo 라는 이름으로 문자열을 보관
session.setAttribute("loginInfo", str);
resp.sendRedirect("/todo/list");
}

 

  - 결과

login 화면에서 아무 id와 pw를 입력하고 login 버튼을 누르면 정상적으로 tood/list로 redirect 됨
로그인 한 사용자만 들어갈 수 있는 todo/register 경로에서 정상적으로 접근 가능


 

4. 필터를 이용한 로그인 체크

  • 로그인 여부를 체크해야 하는 Controller마다 동일하게 체크하는 로직을 작성하면 같은 코드를 계속 작성해야 하므로 대부분 필터를 이용해 처리
  • 필터: 특정 Servlet이나 JSP 등에 도달하는 과정에서 필터링하는 역할을 위해 존재하는 Servlet API
  • @WebFilter 어노테이션을 이용해 특정 경로에 접근 시 필터가 동작하도록 설계하면 동일 로직을 필터로 분리
  • 필터는 한 개 이상, 여러 개 적용 가능
  • 프로젝트에 filter 패키지 구성하고 LoginCheckFilter 클래스 추가
// LoginCheckFilter
package org.zerock.w2.filter;

import lombok.extern.log4j.Log4j2;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;

// '/todo..'로 시작하는 모든 경로에 필터링 시도
@WebFilter(urlPatterns = {"/todo/*"})
@Log4j2
public class LoginCheckFilter implements Filter {

  // doFilter()는 필터가 필터링이 필요한 로직을 구현하는 부분 
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    log.info("Login check filter....");
    
    // 다음 필터나 목적지(Servlet, JSP)로 갈 수 있도록 FilterChain의 doFilter()를 실행
    // 문제가 생겨서 더 이상 진행할 수 없다면 다음 단계로 진행하지 않고 다른 방식으로 redirect 처리 
    chain.doFilter(request, response);
  }
}

 


실습

 3) 로그인 체크 구현

  - LoginCheckFilter에서 '/todo/...'로 시작하는 모든 경로에 접근 시 동작하도록 설정(위의 코드 구체화)

// LoginCheckFilter
package org.zerock.w2.filter;

import lombok.extern.log4j.Log4j2;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

// '/todo..'로 시작하는 모든 경로에 필터링 시도
@WebFilter(urlPatterns = {"/todo/*"})
@Log4j2
public class LoginCheckFilter implements Filter {

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

    log.info("Login check filter....");

    // doFilter에서 HttpServletRequest와 HttpServletResponse보다 상위 타입의 파라미터 사용
    // 따라서 HTTP 관련 작업을 위해 (HttpServlerRequest)request처럼 다운캐스팅 필요
    HttpServletRequest req = (HttpServletRequest)request;
    HttpServletResponse resp = (HttpServletResponse)response;

    HttpSession session = req.getSession();

    // LoginController에서 로그인했을 때 세션에 loginInfo라는 이름으로 저장하도록 하였음
    // 이 loginInfo가 없다면 로그인하지 않은 것으로 판단되므로 LoginCheckFilter에 의해 '/login' 경로로 이동
    if (session.getAttribute("loginInfo") == null) {
      resp.sendRedirect("/login");
      return;
    }
    
    // 어떤 값이든 '/login'에서 로긍니 정보를 전달해서 로그인이 처리되면 그 이후에는 '/todo/...' 경로 이용가능 
    chain.doFilter(request, response);
  }
}

 

 4) UTF-8 처리 필터

  - POST 방식으로 '/todo/register'를 통해 전달되는 문자열은 한글이 깨지므로 HttpServletRequest의 데이터를 SetCharacterEncoding("UTF-8")을 적용해 해결

  - filter 패키지에 UTF8Filter 추가

// UTF8Filter
package org.zerock.w2.filter;

import lombok.extern.log4j.Log4j2;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@WebFilter(urlPatterns = {"/*"})
@Log4j2
public class UTF8Filter implements Filter {

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    log.info("UTF8 filter....");
    HttpServletRequest req = (HttpServletRequest)request;
    req.setCharacterEncoding("UTF-8");

    chain.doFilter(request, response);
  }
}

 

5. 세션을 이용하는 로그아웃 처리

  • HttpSession을 이용하는 경우 로그아웃 처리는 로그인 확인 시 사용했던 정보를 삭제시키는 방식 또는 현재 HttpSession이 더이상 유효하지 않다고 invalidate() 시키는 방식 이용
  • 프로젝트에 LogoutController 추가, '/logout' 경로를 처리하도록 구성
// LogoutController
package org.zerock.w2.controller;

import lombok.extern.log4j.Log4j2;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

@WebServlet("/logout")
@Log4j2
public class LogoutController extends HttpServlet {

  // 로그아웃 처리는 POST 방식으로만 처리되도록 함
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("log out...................");
    HttpSession session = req.getSession();

    // loginInfo라는 이름을 삭제
    session.removeAttribute("loginIngo");
    // 현재 HttpSession이 더 이상 유효하지 않은 것으로 만들기
    session.invalidate();

    // 로그아웃 이후에는 "/"경로로 redirect
    resp.sendRedirect("/");
  }
}
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib uri = "http://java.sun.com/jsp/jstl/core" prefix ="c" %>
<html>
<head>
    <title>Todo List</title>
</head>
<body>
<h1>Todo List</h1>
<ul>
    <c:forEach items = "${dtoList}" var = "dto">
        <li>
            <span><a href = "/todo/read?tno=${dto.tno}">${dto.tno}</a></span>
            <span>${dto.title}</span>
            <span>${dto.dueDate}</span>
            <span>${dto.finished? "DONE": "NOT YET"}</span>
        </li>
    </c:forEach>
</ul>
<!-- 목록화면에서 logout을 post 방식으로 처리할 버튼 추가 -->
<form action = "/logout" method = "post">
    <button>LOGOUT</button>
</form>
</body>
</html>

목록화면에 LOGOUT 버튼이 생기고, 버튼 클릭 시 loginInfo 키가 삭제되고, 현재 HttpSession에서 유효하지 않은 것이 되며, 화면은 초기 화면으로 redirect 됨

 

 

6. 데이터베이스에서 회원 정보 이용하기

  • 데이터베이스 연결 후 회원 정보를 저장한 tbl_member 테이블 생성
  • 회원정보도 임의로 생성
create table tbl_member(
    mid varchar(50) primary key,
    mpw varchar(50) not null,
    mname varchar(100) not null
);

insert into tbl_member (mid, mpw, mname) values ('user00', '1111', '사용자0');
insert into tbl_member (mid, mpw, mname) values ('user01', '1111', '사용자1');
insert into tbl_member (mid, mpw, mname) values ('user02', '1111', '사용자2');

 


실습

 5) 자바에서 회원 데이터 처리하기

  - 자바에서 회원 정보를 객체로 처리하도록 VO / DAO 등을 구현

  - MemberVO와 MemberDAO 구현

// MemberVO
package org.zerock.w2.domain;

import lombok.*;

@Getter
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberVO {

  private String mid;
  private String mpw;
  private String mname;
}
// MemberDAO
package org.zerock.w2.dao;

import lombok.Cleanup;
import org.zerock.w2.domain.MemberVO;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class MemberDAO {

  public MemberVO getWithPassword (String mid, String mpw) throws Exception {

    String query = "select mid, mpw, mname from tbl_member where mid = ? and mpw = ?";
    MemberVO memberVO = null;

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(query);

    preparedStatement.setString(1, mid);
    preparedStatement.setString(2, mpw);

    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();

    resultSet.next();

    memberVO = MemberVO.builder()
        .mid(resultSet.getString(1))
        .mpw(resultSet.getString(2))
        .mname(resultSet.getString(3))
        .build();

    return memberVO;
  }
}

 

  - 서비스 계층과 Controller에서 사용할 MemberDTO와 MemberService 구현

// MemberDTO
package org.zerock.w2.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberDTO {

  private String mid;
  private String mpw;
  private String mname;
}

  - MemberService는 여러 곳에서 동일한 객체를 사용할 수 있도록 enum으로 하나의 객체만 구성하고 MemberDAO를 이용하도록 구성

// MemberService
package org.zerock.w2.service;

import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.zerock.w2.dao.MemberDAO;
import org.zerock.w2.domain.MemberVO;
import org.zerock.w2.dto.MemberDTO;
import org.zerock.w2.util.MapperUtil;

@Log4j2
public enum MemberService {
  INSTANCE;

  private MemberDAO dao;
  private ModelMapper modelMapper;

  MemberService() {
    dao = new MemberDAO();
    modelMapper = MapperUtil.INSTANCE.get();
  }
  
  // 로그인 처리를 위한 login() 메서드 작성
  public MemberDTO login(String mid, String mpw) throws Exception {
    MemberVO vo = dao.getWithPassword(mid, mpw);
    MemberDTO memberDTO = modelMapper.map(vo, MemberDTO.class);
    return memberDTO;
  }
}

 

 6) Controller에서 로그인 연동

  - LoginController의 doPost()에 MemberService를 연동해서 실제 로그인 되도록 수정

// loginController

...

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("login post.............");

    String mid = req.getParameter("mid");
    String mpw = req.getParameter("mpw");

    // 정상적으로 로그인 된 경우, HttpSession을 이용해서 'loginInfo'라는 이름으로 객체 저장
    try {
      MemberDTO memberDTO = MemberService.INSTANCE.login(mid, mpw);
      HttpSession session = req.getSession();
      session.setAttribute("loginInfo", memberDTO);
      resp.sendRedirect("/todo/list");
    } catch (Exception e) {   // 예외 발생 시 '/login'으로 이동, result라는 파라미터를 전달해서 문제가 발생했다는 사실 전달
      resp.sendRedirect("/login?result=error");
    }

...

 

  - EL에서 쿼리 스트링 처리

  - '/WEB-INF/login.jsp'에는 EL에서 기본으로 제공하는 param이라는 객체를 이용해서 result라는 이름으로 전달한 값 확인 가능

<!-- login.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!-- JSTL을 이용 -->
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix = "c" %>
<html>
<head>
    <title>Title</title>
</head>
<body>

<!-- ${param.result}를 이용해서 에러 발생 시 다른 메세지 출력 -->
<c:if test = "${param.result == 'error'}">
    <h1>로그인 에러</h1>
</c:if>

<form action = "/login" method = "post">
  <input type = "text" name = "mid">
  <input type = "text" name = "mpw">
  <button type = "submit">LOGIN</button>
</form>

</body>
</html>

  - tbl_member 테이블에 없는 id와 pw 입력시

 

  - EL의 Scope와 HttpSession 접근하기

  - EL을 이용해서 HttpServletRequest에 setAttribute()로 저장한 객체를 사용할 수 있음

  - EL이 HttpServletRequest에 저장된 객체를 찾을 수 없다면, 자동으로 HttpSession에 저장된 객체를 찾아냄(EL의 Scope)

  - EL의 Scope를 이용해서 접근하는 변수의 종류

  • Page Scope: JSP에서 EL을 이용해 <c:set>으로 저장한 변수
  • Request Scope: HttpServletRequest에 setAttribute()로 저장한 변수
  • Session Scope: HttpSession을 이용해서 setAttribute()로 저장한 변수
  • Application Scope: ServletContext를 이용해서 setAttribute()로 저장한 변수

  ex) EL로 ${obj}라고 하면 앞의 Scope들이 순차적으로 page → request → session → application 순으로 'obj' 라는 이름의 객체 찾는 방식

 

  - 위의 예제에서 'loginInfo'라는 이름으로 MemberDTO를 저장했다면 JSP에는 기존 방식대로 ${loginInfo}라는 이름으로 접근 가능

 

  - '/WEB-INF/todo/list.jsp'는 로그인 한 사용자만 접근 가능한 경로이므로 다음과 같은 코드로 현재 로그인한 사용자의 이름 출력가능

<h2>${loginInfo}</h2>
<h3>${loginInfo.mname}</h3>

1. ModelMapper 라이브러리

  • TodoService와 TodoDTO에 Lombok을 적용시키는 것이 더 좋기 때문에 다시 구성하기
// TodoDTO
package org.zerock.jdbcex.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDate;

@Builder
// @Data는 getter / setter / toString / equals / hashCode 등을 모두 컴파일할 때 생성
// VO에서 getter만 사용하여 읽기 전용으로 구성하는 것과 차이가 있음
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TodoDTO {

  private Long tno;
  private String title;
  private LocalDate dueDate;
  private Boolean finished;
}
// TodoVO
package org.zerock.jdbcex.domain;

import lombok.*;

import java.time.LocalDate;

@Getter
@Builder
@ToString
// 파라미터가 없는 생성자와 모든 필드값이 필요한 생성자를 만들어냄
@AllArgsConstructor
@NoArgsConstructor
public class TodoVO {

  private Long tno;
  private String title;
  private LocalDate dueDate;
  private boolean finished;
}

 

  • DTO → VO, VO → DTO 변환은 ModelMapper 라이브러리를 이용해 처리
  • ModelMapper 라이브러리는 build.gradle파일에서 추가
  • dependencies에 " implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.0.0' " 추가

 

  • 프로젝트에 util 패키지 추가 > ModelMapper의 설정을 변경하고 쉽게 사용할 수 있는 MapperUtil을 enum으로 생성
package org.zerock.jdbcex.util;

import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;

public enum MapperUtil {
  INSTANCE;

  private ModelMapper modelMapper;

  MapperUtil() {
    this.modelMapper = new ModelMapper();
    // ModelMapper의 설정을 변경하려면 getConfiguration()을 이용해서 private로 선언된 필드도 접근할 수 있도록 설정을 변경
    this.modelMapper.getConfiguration()
        .setFieldMatchingEnabled(true)
        .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
        .setMatchingStrategy(MatchingStrategies.STRICT);
  }

  // get()으로 ModelMapper를 사용할 수 있도록 구성
  public ModelMapper get() {
    return modelMapper;
  }
}

 


 

 

 

[자바 웹 개발 워크북] 2.2 - 프로젝트 내 JDBC 구현

1. Lombok 라이브러리 Lombok을 사용하면 자바에서 클래스 작성 시 getter / setter 생성 또는 생성자 함수 정의하는 작업을 간단한 annotation 추가만으로 끝낼 수 있음 getter / setter: @Getter, @Setter, @Data 등을

data-science-study.tistory.com

실습(이전 글에 이어서)

 7) TodoService와 ModelMapper 테스트

  - DTO와 VO 둘 다 이용해야 하는 TodoService를 구성하고 ModelMapper의 동작 확인

// TodoService
package org.zerock.jdbcex.service;

import org.modelmapper.ModelMapper;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.util.MapperUtil;

public enum TodoService {
  INSTANCE;

  private TodoDAO dao;
  private ModelMapper modelMapper;

  TodoService() {
    dao = new TodoDAO();
    modelMapper = MapperUtil.INSTANCE.get();

  }

  // TodoDTO를 파라미터로 받아서 TodoVO로 변환하는 과정을 ModelMapper로 처리
  // print하여 잘 변환됐는지 확인하고
  // TodoDAO를 이용해서 TodoVO를 insert
  public void register(TodoDTO todoDTO) throws Exception {
    TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);
    System.out.println("todoVO: " + todoVO);
    dao.insert(todoVO);
    // insert는 int를 반환하므로 이를 이용한 예외처리도 가능
  }
}

 

  - TodoServieTests 에서 테스트용 코드 작성

package org.zerock.service;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.service.TodoService;

import java.time.LocalDate;

public class TodoServiceTests {

  private TodoService todoService;

  @BeforeEach
  public void ready() {
    todoService = TodoService.INSTANCE;
  }

  @Test
  public void testRegister() throws Exception {
    TodoDTO todoDTO = TodoDTO.builder()
        .title("JDBC Test Title")
        .dueDate(LocalDate.now())
        .build();

    todoService.register(todoDTO);
  }
}

todoVO에 todoDTO로 부터 받은 값이 정상적으로 정의되었음을 확인
tbl_todo 테이블에도 값이 정상적으로 insert되었음을 확인


 

2. Log4j2와 @Log4j2

  • 레벨을 설정하여 개발할 때 필요한 레벨의 로그와 실제 운영 시 필요한 레벨의 로그를 구분
  • 예를 들어, System.out.println은 개발 시에는 확인용으로 많이 사용하지만, 개발이 끝난 후 운영 시에는 필요 x

 

  • Log4j2에는 Appender를 사용하여 어떤 방식으로 기록할 것인지 결정(System.out.println 대신 Consol Appender 사용)
  • Log4j2의 레벨은 중요도의 개념, 로그의 레벨을 지정하면 해당 레벨 이상의 로그들만 출력되어, 개발할 때는 로그의 레벨을 INFO 이하로 개발, 운영할 때는 ERROR나 WARN 이상으로 개발

    • 레벨이 INFO이면 로그에 INFO, WARN, ERROR, FATAL이 출력
    • 레벨이 ERROR이면 로그에 ERROR, FATAL이 출력

 

  • Log4j2 사용을 위해 build.gradle에 라이브러리 설치
// build.gradle의 dependencies에 추가
implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.17.2'
implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.17.2'
implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.17.2'

 

 1) log4j2.xml 설정 파일

  • log4j2 라이브러리의 설정은 log4j2.xml 파일 이용, 해당 파일에 레벨이나 Appender 지정
  • 프로젝트의 resources 폴더 > log4j2.xml 생성
// log4j2.xml
<?xml version="1.0" encoding="UTF-8" ?>
<Configuration status = "WARN">
    <Appenders>
        <Console name = "Console" target = "SYSTEM_OUT">
            <PatternLayout pattern = "%d{HH:mm:ss.SSS} [%t] %-5level %logger{36}- %msg%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level = "info">
            <Appender ref = "Console"/>
        </Root>
    </Loggers>
</Configuration>

 

 2) Log4j2 어노테이션

  • 기존 TodoService 코드에 @Log4j2 어노테이션 추가하고, System.out.println을 log.info()로 변경
package org.zerock.jdbcex.util.service;

import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.util.MapperUtil;

@Log4j2
public enum TodoService {
  INSTANCE;

  private TodoDAO dao;
  private ModelMapper modelMapper;

  TodoService() {
    dao = new TodoDAO();
    modelMapper = MapperUtil.INSTANCE.get();

  }

  public void register(TodoDTO todoDTO) throws Exception {
    TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);
    // System.out.println("todoVO: " + todoVO);
    log.info(todoVO);
    dao.insert(todoVO);

  }
}

    • TodoServiceTests에서 testRegister()를 실행하면 변경된 로그가 출력됨
    • 또한, HikariCP의 로그도 다르게 출력됨(HikariCP가 내부적으로 slf4j 라이브러리를 이용하고 있고, log4j-slf4j-impl 라이브러리가 Log4j2를 이용할 수 있도록 설정되기 때문)

 

  • 테스트 환경에서 @Log4j2 사용하기
    • 테스트 환경에서 @Log4j2 기능을 활용하기 위해 테스트 환경에서도 어노테이션을 처리하는 testAnnotationprocessor와 testCompileOnly 설정 추가
// build gradle의 dependencies에 추가
testCompileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.24'
testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.24'
      • TodoServiceTests에 테스트 코드를 @Log4j2를 이용하도록 수정
package org.zerock.service;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.util.service.TodoService;

import java.time.LocalDate;

@Log4j2
public class TodoServiceTests {

  private TodoService todoService;

  @BeforeEach
  public void ready() {
    todoService = TodoService.INSTANCE;
  }

  @Test
  public void testRegister() throws Exception {
    TodoDTO todoDTO = TodoDTO.builder()
        .title("JDBC Test Title")
        .dueDate(LocalDate.now())
        .build();

    // 테스트 코드에서 Log4j2가 사용됨을 확인
    log.info("---------------------------------");
    // TodoService에서 한 번, TodoServiceTests에서 한 번, 총 두 번 출력되도록 입력
    log.info(todoDTO);
    todoService.register(todoDTO);
  }
}

    • "---------------------------------"가 출력되어 TodoServiceTests에서도 log.info()가 동작함을 확인
    • TodoDTO의 내용이 두 번 출력되어 위의 출력은 TodoServiceTests에서, 밑의 출력은 TodoService에서 된 것을 확인

 

 

3. 컨트롤러와 서비스 객체 연동

  • TodoService와 TodoDAO의 연동을 확인한 후, 마지막으로 Servlet으로 작성된 Controller와 TodoService 연동
  • 실습 ↓↓↓↓↓

 


실습

 1) 목록 기능 구현

  - TodoListcontroller

  - Get 방식

package org.zerock.jdbcex.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.jdbcex.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet(name = "todoListController", value = "/todo/list")
@Log4j2
public class TodoListController extends HttpServlet {
  private TodoService todoService = TodoService.INSTANCE;

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("todo list.................");
  }
}

아직 /todo/list 경로에 화면은 나타나지 않지만 로그에 "todo list..........."가 정상적으로 출력됨을 확인

 

  - TodoService의 목록 기능 구현(개발은 DAO → Service → Controller 순서로 하며, TodoDAO는 개발 완료했으므로 TodoService 에 listAll() 기능 추가)

// TodoService
package org.zerock.jdbcex.service;

import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.util.MapperUtil;

import java.util.List;
import java.util.stream.Collectors;

@Log4j2
public enum TodoService {
  INSTANCE;

  private TodoDAO dao;
  private ModelMapper modelMapper;

  TodoService() {
    dao = new TodoDAO();
    modelMapper = MapperUtil.INSTANCE.get();

  }

  // TodoDTO를 파라미터로 받아서 TodoVO로 변환하는 과정을 ModelMapper로 처리
  // print하여 잘 변환됐는지 확인하고
  // TodoDAO를 이용해서 TodoVO를 insert
  public void register(TodoDTO todoDTO) throws Exception {
    TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);
    // System.out.println("todoVO: " + todoVO);
    log.info(todoVO);
    dao.insert(todoVO);
    // insert는 int를 반환하므로 이를 이용한 예외처리도 가능
  }
  
  // listAll() 메서드 작성
  public List<TodoDTO> listAll() throws Exception {
    List<TodoVO> voList = dao.selectAll();
    log.info("voList....................");
    log.info(voList);
    List<TodoDTO> dtoList = voList.stream()
        .map(vo -> modelMapper.map(vo, TodoDTO.class))
        .collect(Collectors.toList());
    
    return dtoList;
  }
}

  - listAll()은 TodoDAO에서 가져온 TodoVO 목록을 모두 TodoDTO로 변환해서 반환

  - 이때, ModelMapper와 Java Stream의 map()을 이용해 간단히 처리

 

  - TodoListController 수정

  - TodoListController에서 HttpServletRequest의 setAttribute()를 이용해서 TodoService 객체가 반환하는 데이터를 저장하고 RequestDipatcher를 이용해서 JSP로 전달

// TodoListController
package org.zerock.jdbcex.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;

@WebServlet(name = "todoListController", value = "/todo/list")
@Log4j2
public class TodoListController extends HttpServlet {
  private TodoService todoService = TodoService.INSTANCE;

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("todo list.................");

    try {
      List<TodoDTO> dtoList = todoService.listAll();
      req.setAttribute("dtoList", dtoList);
      req.getRequestDispatcher("/WEB-INF/todo/list.jsp").forward(req, resp);
    } catch (Exception e) {
      log.error(e.getMessage());
      throw new ServletException("list error");
    }
  }
}

 

  - WEB-INF 폴더 > todo 디렉토리 생성 > list.jsp 파일 작성

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib uri = "http://java.sun.com/jsp/jstl/core" prefix ="c" %>
<html>
<head>
    <title>Todo List</title>
</head>
<body>
<h1>Todo List</h1>
<ul>
    <c:forEach items = "${dtoList}" var = "dto">
        <li>${dto}</li>
    </c:forEach>
</ul>
</body>
</html>

 

  - 결과

  -  tbl_todo 테이블에 있는 데이터들을 jsp에서 출력

 

 

 2) 등록 기능 구현

  - GET 방식으로 등록 화면을 확인

  - <form> 태그 내에 입력 항목 채운 뒤

  - POST 방식으로 처리

  - 처리 후에는 목록화면으로 redirect하는 PRG(Post - Redirect - get) 패턴 방식

 

  - TodoService의 등록 기능 구현(이전에 만들어 둔 register() 메서드가 TodoService의 등록 기능임)

package org.zerock.jdbcex.service;

import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.util.MapperUtil;

import java.util.List;
import java.util.stream.Collectors;

@Log4j2
public enum TodoService {
  INSTANCE;

  private TodoDAO dao;
  private ModelMapper modelMapper;

  TodoService() {
    dao = new TodoDAO();
    modelMapper = MapperUtil.INSTANCE.get();

  }

  // TodoDTO를 파라미터로 받아서 TodoVO로 변환하는 과정을 ModelMapper로 처리
  // print하여 잘 변환됐는지 확인하고
  // TodoDAO를 이용해서 TodoVO를 insert
  public void register(TodoDTO todoDTO) throws Exception {
    TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);
    // System.out.println("todoVO: " + todoVO);
    log.info(todoVO);
    dao.insert(todoVO);
    // insert는 int를 반환하므로 이를 이용한 예외처리도 가능
  }

  // listAll() 메서드 작성
  public List<TodoDTO> listAll() throws Exception {
    List<TodoVO> voList = dao.selectAll();
    log.info("voList....................");
    log.info(voList);
    List<TodoDTO> dtoList = voList.stream()
        .map(vo -> modelMapper.map(vo, TodoDTO.class))
        .collect(Collectors.toList());

    return dtoList;
  }
}

 

  - TodoRegisterController 구현

  - controller 패키지의 HttpServlet을 상속받도록 선언

  - GET / POST 모두 사용

// TodoRegisterController
package org.zerock.jdbcex.controller;

import com.sun.tools.javac.comp.Todo;
import lombok.extern.log4j.Log4j2;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDate;
import java.util.List;
import java.time.format.DateTimeFormatter;

@WebServlet(name = "todoRegisterController", value = "/todo/register")
@Log4j2
public class TodoRegisterController extends HttpServlet {
  private TodoService todoService = TodoService.INSTANCE;
  private final DateTimeFormatter DATEFORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

  // GET 방식으로 호출되는 경우, "/WEB-INF/todo/register.jsp" 파일에서 입력 화면을 보여줌
  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("/todo/register GET .......");
    req.getRequestDispatcher("/WEB-INF/todo/register.jsp").forward(req, resp);
  }

  // '/todo/register'에서 <form> 태그 내에 title과 dueDate를 POST 방식으로 전송ㄱ
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    TodoDTO todoDTO = TodoDTO.builder()
        .title(req.getParameter("title"))
        .dueDate(LocalDate.parse(req.getParameter("dueDate"), DATEFORMATTER))
        .build();

    log.info("/todo/register POST .......");
    log.info(todoDTO);
    try {
      todoService.register(todoDTO);
    } catch (Exception e) {
      e.printStackTrace();
    }
    resp.sendRedirect("/todo/list");
  }

}

'/todo/register' 경로로 이동했을 때 GET으로 호출된 입력 화면
입력 후 'REGISTER' 클릭 시, 목록 화면으로 Redirect 되어 넘어가고, 목록에는 새로 입력한 데이터가 추가되어 있음

 

 

 3) 조회 기능 구현

  - GET 방식으로 동작

  - '/todo/read?tno=12'와 같이 쿼리 스트링으로 tno 값을 전달

  - TodoService에서 TodoDTO를 반환하고 이를 Controller에서 HttpServletRequest에 담아 JSP에서 출력

// TodoReadController
package org.zerock.jdbcex.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet(name = "todoReadController", value = "/todo/read")
@Log4j2
public class TodoReadController extends HttpServlet {
  private TodoService todoService = TodoService.INSTANCE;

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    try {
      Long tno = Long.parseLong(req.getParameter("tno"));
      TodoDTO todoDTO = todoService.get(tno);

      // dto라는 이름으로 TodoDTO 데이터 담기
      req.setAttribute("dto", todoDTO);
      req.getRequestDispatcher("/WEB-INF/todo/read.jsp").forward(req, resp);
    } catch (Exception e){
      log.error(e.getMessage());
      throw new ServletException("read error");
    }
  }
}
<!-- read.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Todo Read</title>
</head>
<body>
  <div>
    <input type = "text" name = "tno" value = "${dto.tno}" readonly>
  </div>
  <div>
    <input type = "text" name = "title" value = "${dto.title}" readonly>
  </div>
  <div>
    <input type = "date" name = "dueDate" value = "${dto.dueDate}">
  </div>
  <div>
    <input type = "checkbox" name = "finished" value = ${dto.finished ? "checked": ""} readonly>
  </div>
<div>
  <!-- 수정/삭제 또는 목록으로 갈 수 있는 링크 -->
  <a href = "/todo/modify?tno=${dto.tno}">Modify/Remove</a>
  <a href = "/todo/list">List</a>
</div>
</body>
</html>

"http://localhost:8080/todo/read?tno=1" 경로로 접속했을 때 tbl_todo 테이블에서 tno가 1인 행의 데이터가 출력

 

  - 목록에서 조회 링크 처리

  - 조회 기능이 정상임이 확인됐으면 목록 페이지에서 각 목록을 누르면 각 게시물로 바로 이동되도록 링크 걸어주기

<!-- list.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib uri = "http://java.sun.com/jsp/jstl/core" prefix ="c" %>
<html>
<head>
    <title>Todo List</title>
</head>
<body>
<h1>Todo List</h1>
<ul>
    <c:forEach items = "${dtoList}" var = "dto">
        <li>
            // 목록에서 숫자부분을 클릭하면 해당 번호가 쿼리 스트링으로 전달된 read 경로로 조회됨
            <span><a href = "/todo/read?tno=${dto.tno}">${dto.tno}</a></span>
            <span>${dto.title}</span>
            <span>${dto.dueDate}</span>
            <span>${dto.finished? "DONE": "NOT YET"}</span>
        </li>
    </c:forEach>
</ul>
</body>
</html>

각 번호를 누르면 번호에 해당하는 조회 화면으로 이동

 

 4) 수정 / 삭제 기능 구현

  - 두 기능 모두 POST 방식으로 처리

  - 화면에 두 개의 <form> 태그 작성해서 처리 또는 자바스크립트를 이용해 하나의 <form> 태그의 action 속성을 변경해서 처리

 

  - TodoService의 수정 / 삭제 기능 구현

// TodoService
package org.zerock.jdbcex.service;

import com.sun.tools.javac.comp.Todo;
import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.util.MapperUtil;

import java.util.List;
import java.util.stream.Collectors;

@Log4j2
public enum TodoService {
  INSTANCE;

  private TodoDAO dao;
  private ModelMapper modelMapper;

  TodoService() {
    dao = new TodoDAO();
    modelMapper = MapperUtil.INSTANCE.get();

  }

  ...

  // remove() 메서드 작성
  // remove()는 번호만 파라미터로 받아서 해당 번호에 해당하는 데이터 삭제
  public void remove(Long tno) throws Exception {
    log.info("tno: " + tno);
    dao.deleteOne(tno);
  }

  // modify() 메서드 작성
  // modify()는 TodoDTO 타입 전체를 파라미터로 받음
  public void modify(TodoDTO todoDTO) throws Exception {
    log.info("todoDTO: " + todoDTO);
    TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);
    dao.updateOne(todoVO);
  }
}

 

  - TodoModifyController의 구현

// TodoModifyController
package org.zerock.jdbcex.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

@WebServlet(name = "todoModifyController", value = "/todo/modify")
@Log4j2
public class TodoModifyController extends HttpServlet{
  private TodoService todoService = TodoService.INSTANCE;
  private final DateTimeFormatter DATEFORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
  
  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    try {
      Long tno = Long.parseLong(req.getParameter("tno"));
      TodoDTO todoDTO = todoService.get(tno);
      
      // 데이터 담기
      req.setAttribute("dto", todoDTO);
      req.getRequestDispatcher("/WEB-INF/todo/modify.jsp").forward(req, resp);
    } catch (Exception e) {
      log.error(e.getMessage());
      throw new ServletException("modify get... error");
    }
  }
}

 

  - 수정 작업이 이루어지는 'WEB-INF/todo/modify.jsp'

<!-- modify.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Todo Modify</title>
</head>
<body>

<form id = 'form1' action = '/todo/modify' method = 'post'>
  <div>
    <input type = 'text' name = 'tno' value = '${dto.tno}' readonly>
  </div>
  <div>
    <input type = "text" name = "title" value = "${dto.title}">
  </div>
  <div>
    <input type = "date" name = "dueDate" value = "${dto.dueDate}">
  </div>
  <div>
    <input type = "checkbox" name = "finished" value = ${dto.finished ? "checked": ""}>
  </div>
  <div>
    <button type = 'submit'>Modify</button>
  </div>
</form>

<form id = 'form2' action = '/todo/remove' method = 'post'>
  <input type = 'hidden' name = 'tno' value = '${dto.tno}' readonly>
  <div>
    <button type = 'submit'>Remove</button>
  </div>
</form>

</body>
</html>

 

  - TodoModifyController에서 POST 방식으로 동작하는 doPost() 이용해서 처리

// TodoModifyController
package org.zerock.jdbcex.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.jdbcex.dto.TodoDTO;
import org.zerock.jdbcex.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

@WebServlet(name = "todoModifyController", value = "/todo/modify")
@Log4j2
public class TodoModifyController extends HttpServlet{
  
  ...
  
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    String finishedStr = req.getParameter("finished");

    TodoDTO todoDTO = TodoDTO.builder()
        .tno(Long.parseLong(req.getParameter("tno")))
        .title(req.getParameter("title"))
        .dueDate(LocalDate.parse(req.getParameter("dueDate"), DATEFORMATTER))
        .finished(finishedStr != null && finishedStr.equals("on"))
        .build();
    log.info("/todo/modify POST...");
    log.info(todoDTO);
    try {
      todoService.modify(todoDTO);
    } catch (Exception e) {
      e.printStackTrace();
    }
    resp.sendRedirect("/todo/list");
  }
}

  - <form> 태그에서 전송된 title, finished 등을 이용해서 TodoDTO를 구성

  - 만들어진 TodoDTO는 TodoService 객체로 전달되고 목록 화면으로 다시 이동하여 수정된 결과를 볼 수 있음

 

  - TodoRemoveController의 구현

package org.zerock.jdbcex.controller;

import lombok.extern.log4j.Log4j2;
import org.zerock.jdbcex.service.TodoService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet(name = "todoRemoveController", value = "/todo/remove")
@Log4j2
public class TodoRemoveController extends HttpServlet {
  private TodoService todoService = TodoService.INSTANCE;
  
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    Long tno = Long.parseLong(req.getParameter("tno"));
    log.info("tno: "+ tno);
    try {
      todoService.remove(tno);
    } catch (Exception e) {
      log.error(e.getMessage());
      throw new ServletException("read error");
    }
    resp.sendRedirect("/todo/list");
  }
}


 

 1) 코드의 개선 사항들

  • 웹 MVC 구조를 사용하면 확실하게 책임과 역할을 구분할 수 있다는 장점
  • 여러 개의 코드를 만들어야 한다는 단점
  • 개선 사항
    • 여러 개의 Controller를 작성하는 번거로움: TodoDAO나 TodoService와 달리 HttpServlet을 상속하는 여러 개의 Controller 작성해야 함
    • 동일 로직 반복 사용: 게시물 조회나 수정 작업은 둘 다 GET 방식으로 동작, 결과를 보여주는 JSP만 다름, 결국 동일 코드 여러 번 작성
    • 예외 처리 부재: 예외 발생 시 처리에 대한 설계가 없어 비정상적 호출 발생 시 대비 x
    • 반복적 메서드 호출: HttpServletRequset나 HttpServletResponse를 이용 해 TodoDTO를 구성하는 작업 등이 동일한 코드들로 작성되어 개선 필요, Long.parseLong() 드으이 코드도 많이 반복
    • 자바의 객체지향 기법을 좀 더 사용할 필요 o

1. Lombok 라이브러리

  • Lombok을 사용하면 자바에서 클래스 작성 시 getter / setter 생성 또는 생성자 함수 정의하는 작업을 간단한 annotation 추가만으로 끝낼 수 있음
    • getter / setter: @Getter, @Setter, @Data 등을 이용해 자동 생성
    • toString(): @ToString을 이용하여 toString() 메서드 자동 생성
    • equals() / hashCode(): @EqualsAndHashCode를 이용한 자동 생성
    • 생성자 자동 생성: @AllArgsConstructoe, @NoArgsConstructor 등을 이용한 생성자 자동 생성
    • 빌더 생성: @Builder를 이용한 빌더 패턴 코드 생성

 

 1) Lombok 라이브러리 추가

  • jdbcex 프로젝트 내의 build.gradle 파일에서 dependencies를 아래처럼 변경
dependencies {
    compileOnly('javax.servlet:javax.servlet-api:4.0.1')

    testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junitVersion}")

    implementation 'org.mariadb.jdbc:mariadb-java-client:3.0.8'

    compileOnly 'org.projectlombok:lombok:1.18.24'
    annotationProcessor 'org.projectlombok:lombok:1.18.24'

    testCompileOnly 'org.projectlombok:lombok:1.18.24'
    testAnnotationProcessor 'org.projectlombok:lombok:1.18.24'
}

 


실습

 1) TodoVO 클래스 작성

 

[자바 웹 개발 워크북] 2.1 - JDBC 프로그래밍 준비

1. MariaDB 설치·생성 1) MariaDB 설치 2) 데이터베이스 생성과 사용자 계정 추가 데이터베이스에서 필요한 작업을 명령하는 SQL 에디터인 HeidiSQL 프로그램이 같이 설치됨 '신규 ' 버튼을 눌러서 root 계

data-science-study.tistory.com

  - 앞서 데이터베이스에서 만든 tbl_todo 테이블의 데이터를 자바 객체로 처리하기 위해 테이블과 유사한 구조의 TodoVO 클래스와 객체 이용

  - Lombok을 이용하면 반복적으로 생성하는 코드를 줄여 DTO나 VO 작성 시 편리

package org.zerock.jdbcex.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

import java.time.LocalDate;

// VO는 주로 읽기 전용으로 사용하는 경우가 많으므로 @Getter 추가(getTno(), getTitle() 등을 호출 가능)
@Getter
// 객체 생성 시 빌더 패턴을 이용하기 위해 @Builder 추가(TodoVO.builder().build() 형태로 객체 생성 가능)
@Builder
@ToString
public class TodoVO {

  // tbl_todo 테이블의 칼럼들을 기준으로 작성
  private Long tno;
  private String title;
  private LocalDate dueDate;
  private boolean finished;
}

 

2. HikariCP 설정

  • 프로젝트에서 Connection의 생성은 Connection Pool인 HikaariCP 이용
  • build.gradle 파일의 dependencies에 관련 라이브러리 추가
dependencies {

    ...
    
    implementation group: 'com.zaxxer', name: 'HikariCP', version: '5.0.0'
}

 


실습

 2) Connection Pool 이용하기

  - HikariCP 이용을 위해 HikariConfig라는 타입의 객체를 생성해야 함

  - 이전에 만들었던 ConnectTests 파일을 HikariCP를 이용하는 테스트 메서드로 변경하여 작성

package org.zerock.dao;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.sql.Connection;
import java.sql.DriverManager;

public class ConnectTests {

  @Test
  public void testHikariCP() throws Exception {

    HikariConfig config = new HikariConfig();
    config.setDriverClassName("org.mariadb.jdbc.Driver");
    config.setJdbcUrl("jdbc:mariadb://localhost:3306/webdb");
    config.setUsername("webuser");
    config.setPassword("비밀번호");
    config.addDataSourceProperty("cachePrepStmts", "true");
    config.addDataSourceProperty("prepStmtCacheSize", "250");
    config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

    HikariDataSource ds = new HikariDataSource(config);
    Connection connection = ds.getConnection();

    System.out.println(connection);
    connection.close();
  }
}

  - 기존과 동일하게 연결되지만 HikariCP를 통해 연결되었음을 첫 번째 줄에서 알 수 있음

  - 데이터베이스 연결을 많이 할수록 HikariCP 사용 여부에 따른 성능의 차이가 발생

  - 데이터베이스가 원격지에 떨어진 경우 네트워크 연결에 더 많은 시간을 소비해야하므로 HikariCP 사용 여부의 차이가 커짐


 

 1) TodoDAO와 @Cleanup

  • HikariCP를 이용할 수 있게 되면 다음에는 실제 SQL 처리를 전담하는 TodoDAO 구성
  • TodoDAO는 이전에 작성한 TodoService와 연동되어 데이터베이스와 TodoService 간의 연결 담당
  • TodoDAO에서 필요한 작업 수행 시 HikariDataSource를 이용하게 되므로 이에 대한 처리를 쉽게 사용할 수 있도록 ConnectionUtil 클래스를 enum으로 구성하여 사용
// ConnectionUtil
package org.zerock.jdbcex.dao;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import java.sql.Connection;

public enum ConnectionUtil {

  INSTANCE;
  private HikariDataSource ds;

  ConnectionUtil() {
    HikariConfig config = new HikariConfig();
    config.setDriverClassName("org.mariadb.jdbc.Driver");
    config.setJdbcUrl("jdbc:mariadb://localhost:3306/webdb");
    config.setUsername("webuser");
    config.setPassword("yong1998");
    config.addDataSourceProperty("cachePrepStmts", "true");
    config.addDataSourceProperty("prepStmtCacheSize", "250");
    config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

    ds = new HikariDataSource(config);
  }

  public Connection getConnection() throws Exception {
    return ds.getConnection();
  }

}
  • ConnectionUtil은 하나의 객체를 만들어서 사용하는 방식
  • HikariConfig를 이용해서 하나의 HikariDataSource를 구성
  • HikariDataSource는 getConnection()을 통해서 사용하며 외부에서는 ConnecetionUtil.INSTANCE.getConnection()을 통해 Connection을 얻을 수 있도록 함

 

  • TodoDAO에 ConnectionUtil을 사용하는 코드 추가
// TodoDAO
package org.zerock.jdbcex.dao;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class TodoDAO {

  // try-with-resources 기능을 이용하여 try() 내에서 선언된 변수들이 자동으로 close()될 수 있도록 함
  public String getTime() {
     String now = null;
     try(Connection connection = ConnectionUtil.INSTANCE.getConnection();
         PreparedStatement preparedStatement = connection.prepareStatement("select now()");
         ResultSet resultSet = preparedStatement.executeQuery();
         ) {
       resultSet.next();

       now = resultSet.getString(1);
     }catch(Exception e){
       e.printStackTrace();
     }
     return now;
  }

}

 

  • DAO의 동작에 문제가 없는지 테스트 코드 작성
// TodoDAOTests
package org.zerock.dao;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.zerock.jdbcex.dao.TodoDAO;

public class TodoDAOTests {
  private TodoDAO todoDAO;

  @BeforeEach
  public void ready() {
    todoDAO = new TodoDAO();
  }

  @Test
  public void testTime() throws Exception {
    System.out.println(todoDAO.getTime());
  }
}

TodoDAO의 getTime이 작동하며 현재 시간이 출력됨을 확인

 

  • lombok의 @Cleanup: try-with-resource 대신 lombok의 @Cleanup을 이용해 더 깔끔한 코드 생성 가능
    • Cleanup이 추가된 변수는 해당 메서드가 끝날 때 close()가 호출되는 것을 보장
    • getTime()에 @Cleanup 적용한 코드
// TodoDAO
// try-catch 대신 @Cleanup을 사용하여 더 간결한 코드 작성
public String getTime2() throws Exception {
    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement("select now()");
    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();

    resultSet.next();

    String now = resultSet.getString(1);

    return now;
}
    • @Cleanup을 이용하면 Lombok 라이브러리에 상당히 종속적인 코드를 작성하게 된다는 부담이 있지만 최소한의 코드로 close()가 보장된다는 장점

 


실습

 3) TodoDAO의 등록 기능 구현

  - TodoVO 객체를 데이터베이스에 추가하는 기능

  - TodoDAO에 insert() 메서드 구성해서 작성

// TodoDAO
package org.zerock.jdbcex.dao;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.Cleanup;
import org.zerock.jdbcex.domain.TodoVO;

import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class TodoDAO {

  ...
  
  // insert() 메서드 작성
  public void insert(TodoVO vo) throws Exception {

    // sql 구문을 직접 작성하여 전달
    // ?는 나중에 전달할 데이터를 지정, setXXX를 이용하여 실제 값들을 지정(인덱스 번호가 0이 아닌 1부터 시작)
    String sql = "insert into tbl_todo (title, dueDate, finished) values (?, ?, ?)";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    preparedStatement.setString(1, vo.getTitle());
    // LocalDate타입을 지원하지 않으므로 java.sql.Date타입을 이용해 변환하여 추가
    preparedStatement.setDate(2, Date.valueOf(vo.getDueDate()));
    preparedStatement.setBoolean(3, vo.isFinished());

    // insert()는 파아미터로 입력된 TodoVO객체의 정보를 이용해 DML 구문으르 실행하기에 executeUpdate()를 실행하도록 구성
    preparedStatement.executeUpdate();
  }

}

 

  - TodoDAOTests에 insert() 메서드 테스트용 코드 작성

// TodoDAOTests
package org.zerock.dao;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;

import java.time.LocalDate;

public class TodoDAOTests {
  private TodoDAO todoDAO;

  @BeforeEach
  public void ready() {
    todoDAO = new TodoDAO();
  }

  @Test
  public void testInsert() throws Exception {
    TodoVO todoVO = TodoVO.builder()
        .title("Sample Title...")
        .dueDate(LocalDate.of(2022,12,31))
        .build();
    todoDAO.insert(todoVO);
  }
}

테스트 결과, 새로운 데이터가 tbl_todo 테이블에 잘 insert 된 것을 확인

 

 4) TodoDAO의 목록 기능 구현

  - TodoDAO를 이용해서 tbl_todo 내의 모든 데이터 가져오는 기능 구현

  - 테이블의 각 행이 TodoVO의 객체가 되고, 모든 TodoVO를 담을 수 있도록 List<TodoVO>타입을 return

// TodoDAO
package org.zerock.jdbcex.dao;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.Cleanup;
import org.zerock.jdbcex.domain.TodoVO;

import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;

public class TodoDAO {

  ...

  // SelectAll() 메서드 작성
  public List<TodoVO> selectAll() throws Exception {

    String sql = "select * from tbl_todo";
    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();

    List<TodoVO> list = new ArrayList<>();

    while (resultSet.next()) {
      TodoVO vo = TodoVO.builder()
          .tno(resultSet.getLong("tno"))
          .title(resultSet.getString("title"))
          .dueDate(resultSet.getDate("dueDate").toLocalDate())
          .finished(resultSet.getBoolean("finished"))
          .build();
      list.add(vo);
    }
    return list;
  }

}

 

  - TodoDAOTests에 selectAll() 테스트용 코드 작성

// TodoDAOTests
package org.zerock.dao;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;

import java.time.LocalDate;
import java.util.List;

public class TodoDAOTests {
  private TodoDAO todoDAO;

  @BeforeEach
  public void ready() {
    todoDAO = new TodoDAO();
  }

  @Test
  public void testList() throws Exception {
    List<TodoVO> list = todoDAO.selectAll();
    list.forEach(vo -> System.out.println(vo));
  }
}

tbl_todo 테이블의 모든 열이 출력되는지 확인

 

 5) TodoDAO의 조회 기능 구현

  - selectAll()은 tbl_todo의 모든 데이터를 TodoVO 객체로 만들어주는 기능

  - 경우에 따라 특정 번호(tno)의 데이터만 가져오는 기능 필요

  - selectOne() 메서드 작성(특정 번호(tno)를 파라미터로 받고, todoVO가 return)

// TodoDAO
package org.zerock.jdbcex.dao;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.Cleanup;
import org.zerock.jdbcex.domain.TodoVO;

import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;

public class TodoDAO {

  ...
  
  // SelectOne() 메서드 작성
  public TodoVO selectOne(Long tno) throws Exception {
    String sql = "select * from tbl_todo where tno = ";
    
    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);
    
    preparedStatement.setLong(1, tno);
    
    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();
    
    resultSet.next();
    TodoVO vo = TodoVO.builder()
        .tno(resultSet.getLong("tno"))
        .title(resultSet.getString("title"))
        .dueDate(resultSet.getDate("dueDate").toLocalDate())
        .finished(resultSet.getBoolean("finished"))
        .build();
    return vo;
  }

}

 

  - TodoDAOTests에 selectOne() 테스트용 코드 작성

// TodoDAOTests
package org.zerock.dao;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;

import java.time.LocalDate;
import java.util.List;

public class TodoDAOTests {
  private TodoDAO todoDAO;

  @BeforeEach
  public void ready() {
    todoDAO = new TodoDAO();
  }

  @Test
  public void testSelectOne() throws Exception {
    // 이때 테스트용 tno는 반드시 tbl_todo 테이블에 존재하는 번호여야함
    Long tno = 1L;
    TodoVO vo = todoDAO.selectOne(tno);
    System.out.println(vo);
  }
}

tno가 1인 행을 조회했을 때 성공적으로 조회되는 것을 확인
tno가 7인 것을 조회했을 때, tbl_todo에 tno가 7인 것이 없으므로 오류 발생

 

 6) TodoDAO의 삭제 / 수정 기능 구현

  - 삭제 기능은 조회 기능과 비슷하지만 쿼리(select)가 아님

  - 수정 기능은 특정한 번호를 가진 데이터의 제목(title)과 만료일(dueDate), 완료 여부(finish)를 update하도록 구성

// TodoDAO
package org.zerock.jdbcex.dao;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.Cleanup;
import org.zerock.jdbcex.domain.TodoVO;

import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;

public class TodoDAO {

  ...
  
  // deleteOne 메서드 작성
  public void deleteOne(Long tno) throws Exception {
    String sql = "delete from tbl_todo where tno = ?";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    // 삭제할 때도 특정 번호(tno)가 필요
    preparedStatement.setLong(1, tno);

    preparedStatement.executeUpdate();
  }

  // updateOne() 메서드 작성
  public void updateOne(TodoVO todoVO) throws Exception {
    String sql = "update tbl_todo set title = ?, dueDate = ?, finished = ? where tno = ?";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    preparedStatement.setString(1, todoVO.getTitle());
    preparedStatement.setDate(2, Date.valueOf(todoVO.getDueDate()));
    preparedStatement.setBoolean(3, todoVO.isFinished());
    preparedStatement.setLong(4, todoVO.getTno());

    preparedStatement.executeUpdate();
  }

}

 

  - TodoDAOTests에 selectOne() 테스트용 코드 작성

// TodoDAOTests
package org.zerock.dao;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.zerock.jdbcex.dao.TodoDAO;
import org.zerock.jdbcex.domain.TodoVO;

import java.time.LocalDate;
import java.util.List;

public class TodoDAOTests {
  private TodoDAO todoDAO;

  @BeforeEach
  public void ready() {
    todoDAO = new TodoDAO();
  }

  // tno가 1인 첫 번째 데이터의 제목, 날짜, 완료 여부를 다음과 같이 update하도록 test
  @Test
  public void testUpdateOne() throws Exception {
    TodoVO todoVO = TodoVO.builder()
        .tno(1L)
        .title("Sample Title")
        .dueDate(LocalDate.of(2021,12,31))
        .finished(true)
        .build();
    todoDAO.updateOne(todoVO);
  }
}

첫 번째 행이 업데이트 된 tbl_todo 테이블

 

1. MariaDB 설치·생성

 1) MariaDB 설치

 

 2) 데이터베이스 생성과 사용자 계정 추가

  • 데이터베이스에서 필요한 작업을 명령하는 SQL 에디터인 HeidiSQL 프로그램이 같이 설치됨
  • '신규 ' 버튼을 눌러서 root 계정으로 연결
  • 설치할 때 지정했던 암호를 입력하고 열기

 

  • 데이터베이스 생성

 

  • 사용자 계정 생성과 권한 추가

'사용자 관리자'에서 사용자 계정 '추가'
사용자 이름 및 암호 입력 후, 호스트설정을 모든 곳에서 접근 가능하도록 설정, 데이터베이스는 앞에서 생성한 webdb로 설정
접근 허용 권한을 데이터베이스 모두 선택

 

  • 생성된 계정 확인

세션 관리자 메뉴에서 데이터베이스를 사용할 수 있는 지 점검, 암호와 데이터베이스 이름 입력
최종적으로 webdb로 접속된 환경 확인

 

 

2. 프로젝트 생성과 MariaDB 준비

  • 새로운 jdbcex 프로젝트 생성

 

 1) 인텔리제이의 MariaDB 설정

새로 생성한 프로젝트 창에서 사이드바에 Database 버튼을 눌러 데이터베이스 설정
MariaDB에서 생성한 user 정보를 입력하여 connect

  • 데이터베이스가 연동되면 생기는 SQL console 창에 현재 시간 테스트

 

 2) 프로젝트 내 MariaDB 설정

  • 자바와 데이터베이스를 연동하기 위해 JDBC 드라이버라고 부르는 라이브러리 필요
  • build.gradle에 설정 추가
  • 구글에 'mariadb maven'을 검색하여 MariaDB Java Client에서 Gradle 또는 Gradle(Short의 내용을 복사하여 사용

 

  • JDBC 프로그램의 구조
    • JDBC는 Java Database Connectivity이 약자
    • 자바 프로그램과 데이터베이스를 네트워크 상에서 연결해 데이터를 교환하는 프로그램
    • 관련 API로 java.sql과 javax.sql 패키지 사용

 

 3) JDBC 프로그램 작성 순서

  • JDBC 프로그램은 네트워크를 통해 데이터베이스와 연결을 맺고, SQL을 전달해서 데이터베이스가 이를 실행하는 흐름
    1. 네트워크를 통해 데이터베이스와 연결을 맺는 단계
    2. 데이터베이스에서 보낼 SQL을 작성하고 전송하는 단계
    3. 필요하다면 데이터베이스가 보낸 결과를 받아서 처리하는 단계
    4. 데이터베이스와 연결을 종료하는 단계
  • 실습_1. 테스트 프로그램 작성하기
// src > test > java > org.zerock.dao > ConnectTests
package org.zerock.dao;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class ConnectTests {

  // Test를 적용하는 메서드는 반드시 public으로 선언, 파라미터나 return 없이 사용
  @Test
  public void test1() {
    int v1 = 10;
    int v2 = 10;

    // 인자로 받은 두 변수의 값이 동일해야 test에 성공
    Assertions.assertEquals(v1, v2);
  }
}

v1과 v2가 같을 때 test를 성공하여 출력된 결과
v2를 20으로 바꿔 v1과 v2를 다르게 했을 때 test에 실패 후 출력되는 결과

    • 이를 이용하여 MariaDB와의 연결을 확인하는 용도의 코드 작성
package org.zerock.dao;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.sql.Connection;
import java.sql.DriverManager;

public class ConnectTests {

  @Test
  public void testConnection() throws Exception {

    // JDBC 드라이버 클래스를 메모리상으로 로딩하는 역할
    // 문자열은 패키지명과 클래스명의 대소문자까지 정확히 일치
    Class.forName("org.mariadb.jdbc.Driver");

    // java.sql 패키지의 Connection 인터페이스 타입의 변수
    // 데이터베이스와의 네트워크 연결을 의미
    Connection connection = DriverManager.getConnection(
        
        // jdbc 프로토콜을 이용한다는 의미
        // localhost:3306은 네트워크 연결정보,
        // webdb는 연결하려는 데이터베이스 정보 의미
        "jdbc:mariadb://localhost:3306/webdb",

        // 연결을 위해 필요한 사용자 계정과 패스워드
        "webuser",
        "비밀번호");
    
    // 데이터베이스와 정상적으로 연결이 된가면 Connection 타입의 객체는 null이 아니라는 것을 확신
    Assertions.assertNotNull(connection);

    // 작업이 완료되면 반드시 데이터베이스와의 연결을 종료
    connection.close();
  }
}

데이터베이스와 Connection에 성공하여 출력된 결과

 

  • 실습_2. 데이터베이스 테이블 생성
    • 관계형 데이터베이스에서는 데이터 저장을 위해 테이블 생성
    • 테이블은 여러 칼럼과 로우로 구성
    • 각 칼럼에는 이름과 타입, 제약 조건 등이 결합
    • MariaDB에서 사용하는 데이터 타입
타입 용도 크기 설명
숫자형 데이터 타입
TINYINT 매우 작은 정수 1 byte -128 ~ 127 (부호 없이 0 ~ 255)
SMALLINT 작은 정수 2 byte -32768 ~ 32767
MEDIUMINT 중간 크기의 정수 3 byte -(-8388608) ~ -1(8388607)
INT 표준 정수 4 byte -2147483648 ~ 2147483647
(부호 없이 0 ~ 4294967295)
BIGINT 큰 정수 8 byte -2147483648 ~ 2147483647
(부호 없이 0 ~ 4294967295)
FLOAT 단정도 부동 소수 4 byte -9223372036854775808 ~ 9223372036854775807
(부호 없이 0 ~ 18446744073709551615)
DOUBLE 배정도 부동 소수 8 byte -1.7976E+320 ~ -1.7976E+320
(부호 없이 쓸 수 없음)
DECIMAL(m, n) 고정 소수 m과 n에 따라 다름 숫자 데이터이지만 내부적으로 String 형태로 저장됨, 최개 65자
BIT(n) 비트 필드 n에 따라 다름 1 ~ 64 bit 표현
날짜형 데이터 타입
DATE (형태)YYYY-MM-DD 3 byte 1000-01-01 ~ 9999-12-31
DATETIME (형태)YYYY-MM-DD
          hh:mm:ss
8 byte 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59
TIMESTAMP (형태)YYYY-MM-DD
          hh:mm:ss
4 byte 1970-01-01 00:00:00 ~ 2037
TIME (형태)hh:mm:ss 3 byte -839:59:59 ~ 839:59:59
YEAR (형태)YYYY 또는 YY 1 byte 1901 ~ 2155
문자형 데이터 타입
CHAR(n) 고정 길이 비이진 문자열 n byte  
VARCHAR(n) 가변 길이 비이진 문자열 Length + 1 byte  
BINARY(n) 고정 길이 이진 문자열 n byte  
VARBINARY(n) 가변 길이 이진 문자열 Length + 1 byte or 2 byte  
TINYBLOB 매우 작은 Binary Large Object Length + 1 byte  
BLOB 작은 Binary Large Object Length + 2 byte 최대 크기 64KB
MEDIUMBLOB 중간 크기 Binary Large Object Length + 3 byte 최대 크기 16MB
LONGBLOB 큰 Binary Large Object Length + 4 byte 최대 크기 4GB
TINYTEXT 매우 작은 비이진 문자열 Length + 1 byte  
TEXT 작은 비이진 문자열 Length + 1 byte 최대 크기 64KB
MEDIUMTEXT 중간 크기 비이진 문자열 Length + 3 byte 최대 크기 16MB
LONGTEXT 큰 비이진 문자열 Length + 4 byte 최대 크기 4GB

 

    • Todo 리스트를 저장하기 위한 테이블 생성
create table tbl_todo (
    tno int auto_increment primary key,
    title varchar(100) not null,
    dueDate date not null,
    finished tinyint default 0
);
    • tbl_todo라는 이름으로 생성
    • tno는 primary key로 사용하며, auto_increment는 새로운 데이터 추가 시 자동으로 새로운 번호가 생성되도록 함
    • dueDate는 '년-월-일'로 기록할 수 있는 date타입 이용
    • MariaDB에서 boolean 값은 true / false 값 대신 0 / 1로 사용하는 경우가 많으므로 tinyint타입으로 처리

정상적으로 생성된 tbl_todo

 

  • 실습_3. 데이터 insert
    • 데이터 추가
insert into tbl_todo (title, dueDate, finished)
values  ('Test...', '2022-12-31', 1);

위의 코드 여러 번 실행
실행 결과 tbl_todo에 데이터 여러 개가 추가 된 모습, tno는 자동으로 1부터 5까지 추가되어 있음

 

  • 실습_4. 데이터 select
    • 데이터 조회
    • 'from'으로 데이터를 가져올 테이블 지정
    • 'where로 조회할 데이터의 조건 지정
select * from tbl_todo where tno=1;

 

  • 실습_5. 데이터 update
    • 기존 데이터 수정
    • 'set'으로 특정 칼럼 내용 수정
    • 'where'로 수정할 데이터의 조건 지정
    • 3번 데이터의 finished와 title 값을 변경하고 싶다면 아래의 코드 작성
update tbl_todo set finished = 0, title = 'Not Yet...' where tno = 3;

 

  • 실습_6. 데이터 delete
    • 데이터 삭제
    • 'where ' 조건에 해당하는 데이터 삭제
    • 'where' 조건이 없다면 모든 데이터 삭제할 수 있으므로 경고 메세지와 함께 실행되지 않음
    • tno가 5보다 큰 데이터를 삭제하고 싶다면 아래의 코드 작성
delete from tbl_todo where tno > 5;

 

 4) DML과 쿼리(select)의 차이

  • DML(insert, update, delete)와 select의 차이
    • DML은 몇 개의 데이터가 처리되었는지 숫자로 결과 반환
    • select문은 데이터를 반환
  • update문 실행 예시

한 개의 row가 영향을 받았다고 숫자로 결과가 출력됨

  • select문 실행 예시
select * from tbl_todo;

실제 데이터인 tbl_todo 표 자체가 출력됨

 

 5) JDBC 프로그래밍을 위한 API와 용어들

  • java.sql.Connection
    • Connection 인터페이스는 데이터베이스와 네트워크 상의 연결을 의미
    • 데이터베이스에 SQL을 실행하기 위해 반드시 정상적인 Connection 타입의 객체 생성해야 함
    • 가장 중요한 사실은 "Connection은 반드시 close()해야 한다", 연결이 종료되지 않으면 새로운 연결을 받을 수 없는 상황이 발생함
    • Connection 종료를 위해 try ~ catch ~ finally 또는 try-with-resource를 사용(후자 사용 시 자동으로 close()가 호출됨)
    • 가장 중요한 기능은 Statement 혹은 Prepared-Statement 등 SQL을 실행할 수 있는 객체를 생성하는 기능
// Prepared-Statement 객체 생성 코드
Connection connection = ...
PreparedStatement preparedStatement = connection.preparedStatement("select * from tbl_todo");

 

  • java.sql.Statement / PreparedStatement
    • JDBC에서 SQL을 데이터베이스로 보내기 위해 Statement / PreparedStatement 타입 이용
    • PreparedStatemetn는 SQL문을 미리 전달하고 나중에 데이터를 보내는 방식
    • Statement는 SQL문 내부에서 모든 데이터를 같이 전송하는 방식
    • 실제 개발에서는 SQL 내부에 고의적으로 다른 SQL 문을 심는 SQL injection을 막기 위해 PreparedStatement만 사용
    • Statement / PreparedStatement의 주요 기능
      • setXXX(): setInt(), setString(), setDate()와 같이 다양한 타입에 맞게 데이터 세팅
      • executeUpdate(): DML을 실행하고 결과를 int 타입으로 반환(몇 행이 영향을 받았는지)
      • executeQuery(): 쿼리(select)를 실행할 때 사용, ResultSet이라는 return 타입 이용
    • Statement도 Connection처럼 마지막에 close() 해주어야, 데이터베이스 내부에서도 메모리와 같이 사용했던 자원들이 즉각 정리됨

 

  • java.sql.ResultSet
    • 쿼리(select)를 실행했을 때 반환하는 데이터를 읽어들이기 위한 인터페이스
    • 자바 코드에서 데이터를 읽어 들이기 때문에 getInt(), getString() 등의 메서드를 이용해서 필요한 타입으로 데이터를 읽어 들임
    • ResultSet의 메서드 next(): ResultSet은 데이터를 순차적으로 읽는 방식으로 구성되기 때문에 next()를 이용해 다음 행의 데이터를 읽을 수 있도록 이동하는 작업이 필요
    • ResultSet 역시 마지막에 close() 해주어야 데이터베이스에서 자원을 즉각 회수

 

  • Connection Pool과 DataSource
    • JDBC 프로그램은 기본적으로 필요한 순간 잠깐 데이터베이스과 네트워크로 연결하고 데이터를 주고 받는 방식
    • 이 과정에서 데이터베이스와 연결을 맺는 작업은 많은 시간과 자원을 쓰므로 SQL을 여러 번 실행하면 성능 저하
    • 이 때, Connection Pool을 이용하여 문제 해결
    • Connection Pool: 미리 Connection들을 생성해 보관, 필요할 때 꺼내 쓰는 방식
    • javax.sql.DataSource 인터페이스는 Connection Pool을 자바에서 API 형태로 지원
    • Connection Pool은 이미 작성된 라이브러리 이용(DBCP, C3PO, HikariCP 등)

 

  • DAO(Data Access Object)
    • 데이터를 전문적으로 처리하는 객체
    • 데이터베이스의 접근과 처리를 전담하는 객체이고, 주로 VO(Value Object, 읽을 수 있는 값) 단위로 처리
    • DAO를 호출하는 객체는 DAO 내부에서 어떤 식으로 데이터를 처리하는지 알 수 없도록 구성

 

  • VO(Value Object) 혹은 엔티티(Entity)
    • 객체지향 프로그램은 데이터를 객체 단위로 처리(ex)테이블 한 행이 자바 프로그램에서 하나의 객체)
    • 데이터베이스에서는 하나의 데이터를 하나의 엔티티라고 하며 자바 프로그램은 이를 처리하기 위해 테이블과 유사한 구조의 클래스를 만들어 객체로 처리
    • 이때 만든 객체는 값을 보관하는 용도라는 의미에서 VO라고 함
    • DTO는 각 계층을 오고 가는데 사용되는 택배 상자와 비슷 / VO는 데이터베이스의 엔티티를 자바 객체로 표현
    • DTO는 getter / setter를 이용해 자유롭게 데이터 가공 / VO는 주로 데이터 자체를 의미하므로 getter만 사용

+ Recent posts