스터디 레포트 202403 #3

Table of Contents

[TOC]

[ ←전편보기 / 1편, 2편, 4편, 5편, 6편 / 다음편보기→ ]

결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403

github : https://github.com/lupfeliz/study-202403

1. 개요

지난편에서는 맨땅에서 SpringBoot 개발환경 및 공통 유틸리티 등을 작성해 보았다.
이번 편에서는 아래와 같이 본격 업무 로직을 작성해 볼 거다.

src
├── java/my/was/mywas/works
│   ├── atc [게시물]
│   │   ├── ArticleController.java [컨트롤러]
│   │   ├── Article.java [DTO]
│   │   ├── ArticleRepository.java [리포지터리]
│   │   └── ArticleService.java [서비스]
│   ├── cmn [공통]
│   │   ├── CommonController.java [컨트롤러]
│   │   ├── CommonRepository.java [리포지터리]
│   │   └── CommonService.java [서비스]
│   ├── lgn [로그인]
│   │   ├── LoginController.java [컨트롤러]
│   │   └── LoginService.java [서비스]
│   └── usr [회원관리]
│       ├── UserController.java [컨트롤러]
│       ├── User.java [DTO]
│       ├── UserRepository.java [리포지터리]
│       └── UserService.java [서비스]
└── resources
    └── mappings [JPA ORM 맵핑 파일들]
        ├── orm-article.xml [게시물]
        └── orm-common.xml [공통]

2. 기존 설정 수정

먼저 기존 파일들을 조금 수정해야 한다.

2-1. SecurityConfig.java

  • 먼저 전편에서 깜빡하고 지나쳤던 보안설정 (SecurityConfig)을 아래와 같이 수정한다

package my.was.mywas.configs;

import static my.was.mywas.commons.Constants.TOK_TYP_ACC;
import static my.was.mywas.commons.WebUtils.getAuthToken;

... 중략 ...

  @Component public static class AuthFilter extends GenericFilterBean {
    /** 토큰발급기 */
    @Autowired TokenProvider tokenProvider;
    @Override public void doFilter(ServletRequest sreq, ServletResponse sres, FilterChain chain)
      throws IOException, ServletException {
      HttpServletRequest req = (HttpServletRequest) sreq;
      req.setAttribute(HttpServletResponse.class.getName(), sres);
      String token = getAuthToken(req);
      log.trace("TOKEN:{}", token);
      Claims claims = null;
      /** 토큰이 정상검증되면 인증정보를 현재 컨텍스트(리퀘스트) 에 저장한다. */
      if (token != null && (claims = tokenProvider.parseToken(TOK_TYP_ACC, token, req)) != null) {
        Authentication auth = tokenProvider.getAuth(claims);
        SecurityContextHolder.getContext().setAuthentication(auth);
      }
      chain.doFilter(sreq, sres);
    }
  }

... 중략 ...

2-2. application.yml

  • JPA ORM 파일들을 추가해 주기 위해 아래와 같이 수정한다.


... 중략 ...

 jpa:

    ... 중략 ...

    mapping-resources:
      - mappings/orm-article.xml
      - mappings/orm-common.xml

... 중략 ...

  • 이후에 아래와 같은 내용으로 각각 orm-article.xml, orm-common.xml 2개의 공파일을 만들어준다

  • 위치는 /src/main/resources/mappings 폴더 이며 상세 내용은 업무로직 구현시 작성한다.

<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings 
  xmlns="http://java.sun.com/xml/ns/persistence/orm"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm http://java.sun.com/xml/ns/persistence/orm_2_0.xsd"
  version="2.0">
</entity-mappings>

3. 업무 로직 구현

  • 업무로직은 1편 : 분석 및 설계 를 참고하여 구현한다

  • 파트는 크게 공통, 회원, 게시물, 로그인 등으로 나눈다

  • 일반적으로 파트당 컨트롤러(**Controller), 서비스(**Service), 리포지터리(**Repository) 그리고 DTO 등의 파일을 구현할거다

3-1. 공통 파트 구현 (cmn)

3-1-1. CommonRepository 구현

  • 공통으로 사용하는 DB 작업을 구현한다. 회원가입, 마이페이지 및 로그인 등에 사용되는 DB 암호화 등에 필요.

  • 로그인절차 참고

  • /src/main/java/my/was/mywas/works/cmn/CommonRepository.java JPA 파일을 작성한다.

package my.was.mywas.works.cmn;

import static com.ntiple.commons.ConvertUtil.cast;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;

@Slf4j @Repository
public class CommonRepository {

  @Autowired private EntityManager em;

  /** DB 자체 기능을 사용한 암호화 encrypt, 단방향 암호화만 사용한다.  */
  public String dbEncrypt(String value) {
    String ret = "";
    try {
      /** JPA 질의문(Common.dbEncrypt)을 수행한다 */
      ret = cast(em.createNamedQuery("Common.dbEncrypt")
        .setParameter("value", value)
        .getSingleResult(), ret);
      log.debug("RESULT:{}", ret);
    } catch (Exception e) {
      log.error("", e);
    }
    return ret;
  }
}

  • /src/main/resources/mappings/orm-common.xml ORM 파일을 작성한다.

<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings 
  xmlns="http://java.sun.com/xml/ns/persistence/orm"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm http://java.sun.com/xml/ns/persistence/orm_2_0.xsd"
  version="2.0">
  <package>my.was.mywas.works.cmn</package>
  <named-native-query name="Common.dbEncrypt">
    <query>
    <![CDATA[
    SELECT RAWTOHEX(HASH('SHA256', :value, 1000))
    ]]>
    </query>
  </named-native-query>
</entity-mappings>

3-1-2. CommonService 구현

  • 공통 서비스, 환경정보 및 암복호화 키 교환 등을 담당한다.

  • 암호 교환 및 검증 절차 참고

  • /src/main/java/my/was/mywas/works/cmn/CommonService.java 파일을 작성한다.

package my.was.mywas.works.cmn;

import static com.ntiple.commons.Constants.UTF8;
import static my.was.mywas.commons.Constants.KOKR;
import static my.was.mywas.commons.Constants.RESCD_FAIL;
import static my.was.mywas.commons.Constants.RESCD_OK;

import java.util.Date;

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

import com.ntiple.commons.CryptoUtil.AES;
import com.ntiple.commons.CryptoUtil.RSA;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.InitObj;
import my.was.mywas.commons.CommonEntity.Result;
import my.was.mywas.commons.SystemSettings;

@Slf4j @Service
public class CommonService {

  private static CommonService instance;

  @Autowired private SystemSettings settings;

  @Autowired private CommonRepository repository;

  @PostConstruct public void init() {
    log.trace("INIT:{}", CommonService.class);
    instance = this;
  }

  public static CommonService getInstance() {
    return instance;
  }

  /** 시스템 활성화 여부 체크 */
  public Result cmn00000000() throws Exception {
    String rescd = RESCD_OK;
    if (!settings.isAlive()) { rescd = RESCD_FAIL; }
    Result ret = Result.builder()
      .rescd(rescd)
      .build();
    return ret;
  }

  /** 최초 접속 환경정보 조회 */
  public InitObj cmn01001a01() throws Exception {
    long timestamp = System.currentTimeMillis();
    return InitObj.builder()
      .current(new Date(timestamp))
      .locale(KOKR)
      .encoding(UTF8)
      .expirecon(settings.getExprAcc())
      .check(rsaEncrypt(settings.getKeySecret()))
      .build();
  }

  /** DB 암호화 */
  public String dbEncrypt(String value) {
    return repository.dbEncrypt(value);
  }

  /** AES 암호화 */
  public String aesEncrypt(String value) {
    try {
      return AES.encrypt(settings.getKeySecret(), value);
    } catch (Exception e) {
      log.debug("E:{}", e.getMessage());
    }
    return "";
  }

  /** AES 복호화 */
  public String aesDecrypt(String value) {
    try {
      return AES.decrypt(settings.getKeySecret(), value);
    } catch (Exception e) {
      log.debug("E:{}", e.getMessage());
    }
    return "";
  }

  /** RSA 암호화 (AES 키 전송용) */
  public String rsaEncrypt(String value) {
    try {
      return RSA.encrypt(1, settings.getKeyPublic(), value);
    } catch (Exception e) {
      log.debug("E:{}", e.getMessage());
    }
    return "";
  }

  /** RSA 복호화 (AES 키 전송용) */
  public String rsaDecrypt(String value) {
    try {
      return RSA.decrypt(1, settings.getKeyPublic(), value);
    } catch (Exception e) {
      log.debug("E:{}", e.getMessage());
    }
    return "";
  }
}

3-1-3. CommonController 구현

  • /src/main/java/my/was/mywas/works/cmn/CommonController.java 파일을 작성한다.

package my.was.mywas.works.cmn;

import static my.was.mywas.commons.RestResponse.response;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.InitObj;
import my.was.mywas.commons.CommonEntity.Result;

@Slf4j @RestController
@RequestMapping("/api/cmn")
public class CommonController {

  static final String CONTROLLER_TAG1 = "공용 API"; 

  @Autowired CommonService service;

  @PostConstruct public void init() {
    log.trace("INIT:{}", CommonController.class);
  }

  @Operation(summary = "시스템 활성화 여부 체크 (cmn00000000)", tags = { CONTROLLER_TAG1 })
  @GetMapping("/cmn00000")
  public ResponseEntity<Result> cmn00000000() {
    return response(() -> service.cmn00000000());
  }

  @Operation(summary = "최초 접속 환경정보 조회 (cmn01001a01)", tags = { CONTROLLER_TAG1 })
  @GetMapping("/cmn01001")
  public ResponseEntity<InitObj> cmn01001a01() {
    return response(() -> service.cmn01001a01());
  }
}

3-2. 회원관리 파트 구현 (usr)

3-2-1. User 구현

  • 회원관리 정보 DTO 를 작성한다.

  • /src/main/java/my/was/mywas/works/usr/User.java 파일을 작성한다.

package my.was.mywas.works.usr;

import java.util.Date;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import my.was.mywas.commons.CommonEntity.SecureOut;

@Entity
@Table(name = "TB_USER")
@Schema(title = "사용자 (User)")
@NoArgsConstructor @AllArgsConstructor
@Getter @Setter @Builder @ToString @SecureOut
public class User {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  @Schema(title = "사용자 고유번호")
  private Long id;

  @Column(name = "user_id", length = 32, unique = true)
  @Schema(title = "사용자 아이디")
  private String userId;

  @Column(name = "user_nm", length = 32)
  @Schema(title = "사용자 이름")
  private String userNm;

  @Column(name = "passwd", length = 128)
  @Schema(title = "사용자 비밀번호")
  @SecureOut private String passwd;

  @Column(name = "email", length = 128)
  @Schema(title = "사용자 이메일")
  private String email;

  @Column(name = "ctime")
  @Schema(title = "생성일시")
  private Date ctime;

  @Column(name = "utime")
  @Schema(title = "수정일시")
  private Date utime;
}

3-2-2. UserRepository 구현

  • /src/main/java/my/was/mywas/works/usr/UserRepository.java JPA 파일을 작성한다.

package my.was.mywas.works.usr;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/** JPA 에서는 기본적으로 메소드명으로 질의문을 자동생성 가능하므로 필요한 질의문만 기술한다 */
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

  /** 회원id 가 존재하는지 체크 */
  Integer countByUserIdEquals(String userId) throws Exception;

  /** 회원Id 로 회원정보 조회 */
  User findOneByUserIdEquals(String userId) throws Exception;

  /** 회원일련번호 로 회원정보조회 */
  User findOneById(Long id) throws Exception;
}

  • 회원정보 관리는 복잡한 질의가 없으므로 orm 파일이 불필요하다.

3-2-3. UserService 구현

package my.was.mywas.works.usr;

import static com.ntiple.commons.ConvertUtil.convert;
import static my.was.mywas.commons.Constants.NOT_PERMITTED_USER;
import static my.was.mywas.commons.Constants.RESCD_FAIL;
import static my.was.mywas.commons.Constants.RESCD_OK;

import java.util.Date;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.ApiException;
import my.was.mywas.commons.CommonEntity.AuthDetail;
import my.was.mywas.commons.CommonEntity.Result;
import my.was.mywas.works.cmn.CommonService;

@Slf4j @Service
public class UserService {

  @Autowired private CommonService cmnservice;
  @Autowired private UserRepository repository;

  @PostConstruct public void init() {
    log.trace("INIT:{}", UserService.class);
  }

  /** 질의한 userId 로 사용하는 사람이 없다면 OK */
  public Result usr01001a01(String userId) throws Exception {
    Result ret = Result.builder()
      .rescd(RESCD_FAIL)
      .build();
    if (repository.countByUserIdEquals(userId) == 0) {
      ret.setRescd(RESCD_OK);
    }
    return ret;
  }

  /** 회원정보 저장 */
  public Result usr01001a02(User prm) throws Exception {
    Result ret = new Result();
    String userId = prm.getUserId();
    String password = prm.getPasswd();
    Date date = new Date();
    /** 비밀번호 평문화 */
    String dpassword = cmnservice.aesDecrypt(password);
    /** DB 저장을 위해 재암호화 */
    String epassword = cmnservice.dbEncrypt(dpassword);
    /** 비밀번호 노출 우려가 있으므로 실제로는 출력을 제한한다. */
    log.debug("USER-ID:{} / PASSWORD:{} / {} / {}", userId, password, dpassword, epassword);
    prm.setPasswd(epassword);
    prm.setCtime(date);
    prm.setUtime(date);
    log.debug("PRM:{}", prm);
    repository.save(prm);
    ret.setRescd(RESCD_OK);
    return ret;
  }

  /** 마이페이지 조회 */
  public User usr01002a01(String userId) throws Exception {
    User ret = null;
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    log.debug("AUTH:{} / {}", auth.getName(), auth.getDetails());
    AuthDetail detail = convert(auth.getDetails(), new AuthDetail());
    log.debug("DETAIL:{}", detail);
    /** 로그인된 사용자가 다르다면 조회불가능 */
    if (userId != null && userId.equals(auth.getName())) {
      ret = repository.findOneByUserIdEquals(userId);
    }
    return ret;
  }

  /** 회원정보 수정 (마이페이지 저장) */
  public Result usr01002a02(User prm) throws Exception {
    Result ret = new Result();
    log.debug("PRM:{}", prm);
    String password = prm.getPasswd();
    Date date = new Date();
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    User user = prm;
    if (user.getId() != null && user.getId() != 0) {
      user = repository.findOneById(user.getId());
      /** 로그인된 사용자가 다르다면 오류발생 */
      if (!user.getUserId().equals(auth.getName())) { throw new ApiException(0, NOT_PERMITTED_USER); }
      if (password != null && !"".equals(password)) {
        /** 비밀번호 평문화 */
        String dpassword = cmnservice.aesDecrypt(password);
        /** DB 저장을 위해 재암호화 */
        String epassword = cmnservice.dbEncrypt(dpassword);
        /** 비밀번호 노출 우려가 있으므로 실제로는 출력을 제한한다. */
        log.debug("USER-ID:{} / PASSWORD:{} / {} / {}", user.getUserId(), password, dpassword, epassword);
        user.setPasswd(epassword);
      }
      user.setEmail(prm.getEmail());
      user.setUtime(date);
      log.debug("PRM:{}", user);
      repository.save(user);
      ret.setRescd(RESCD_OK);
    }
    return ret;
  }

  /** 회원정보 삭제 */
  public Result usr01002a03(String userId) throws Exception {
    Result ret = new Result();
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (userId != null) {
      User user = repository.findOneByUserIdEquals(userId);
      if (!user.getUserId().equals(auth.getName())) {
        throw new ApiException(0, NOT_PERMITTED_USER);
      }
      repository.delete(user);
      ret.setRescd(RESCD_OK);
    }
    return ret;
  }
}

3-2-4. UserController 구현

  • /src/main/java/my/was/mywas/works/usr/UserController.java 파일을 작성한다.

package my.was.mywas.works.usr;

import static my.was.mywas.commons.RestResponse.response;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.Result;

@Slf4j @RestController
@RequestMapping("/api/usr")
public class UserController {

  private static final String CONTROLLER_TAG1 = "사용자 API"; 

  @Autowired UserService usrservice;

  @PostConstruct public void init() {
    log.trace("INIT:{}", UserController.class);
  }

  @Operation(summary = "아이디 중복확인 (usr01001a01)", tags = { CONTROLLER_TAG1 })
  @GetMapping("/usr01001/{userId}")
  public ResponseEntity<Result> usr01001a01(
    @PathVariable(value = "userId") @Parameter(description = "사용자 ID") String userId) {
    return response(() -> usrservice.usr01001a01(userId));
  }

  @Operation(summary = "회원가입 (usr01001a02)", tags = { CONTROLLER_TAG1 })
  @PutMapping("/usr01001")
  public ResponseEntity<Result> usr01001a02(@RequestBody User prm) {
    return response(() -> usrservice.usr01001a02(prm));
  }

  @Operation(summary = "마이페이지 정보조회 (usr01002a01)", tags = { CONTROLLER_TAG1 })
  @GetMapping("/usr01002/{userId}")
  public ResponseEntity<User> usr01002a01(
    @PathVariable(value = "userId") @Parameter(description = "사용자 ID") String userId) {
    return response(() -> usrservice.usr01002a01(userId));
  }

  @Operation(summary = "마이페이지 수정 (usr01002a02)", tags = { CONTROLLER_TAG1 })
  @PutMapping("/usr01002")
  public ResponseEntity<Result> usr01002a02(@RequestBody User prm) {
    return response(() -> usrservice.usr01002a02(prm));
  }

  @Operation(summary = "회원정보 삭제 (usr01002a03)", tags = { CONTROLLER_TAG1 })
  @DeleteMapping("/usr01002/{userId}")
  public ResponseEntity<Result> usr01002a03(
    @PathVariable(value = "userId") @Parameter(description = "사용자 ID") String userId) {
    return response(() -> usrservice.usr01002a03(userId));
  }
}

3-3. 로그인 파트 구현 (lgn)

3-3-1. LoginService 구현

  • 로그인, 로그인연장 업무분석 참고

  • /src/main/java/my/was/mywas/works/lgn/LoginService.java 파일을 작성한다.

package my.was.mywas.works.lgn;

import static com.ntiple.commons.ConvertUtil.arr;
import static com.ntiple.commons.ConvertUtil.convert;
import static com.ntiple.commons.ConvertUtil.tomap;
import static my.was.mywas.commons.Constants.EXTRA_INFO;
import static my.was.mywas.commons.Constants.TOK_TYP_ACC;
import static my.was.mywas.commons.Constants.TOK_TYP_REF;
import static my.was.mywas.commons.WebUtils.curRequest;
import static my.was.mywas.commons.WebUtils.getAuthToken;
import static my.was.mywas.commons.WebUtils.remoteAddr;

import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;

import io.jsonwebtoken.Claims;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.AuthInfo;
import my.was.mywas.commons.CommonEntity.AuthResult;
import my.was.mywas.commons.CommonEntity.Login;
import my.was.mywas.commons.ApiException;
import my.was.mywas.commons.SystemSettings;
import my.was.mywas.commons.TokenProvider;
import my.was.mywas.works.cmn.CommonService;
import my.was.mywas.works.usr.User;
import my.was.mywas.works.usr.UserRepository;

@Slf4j @Service
public class LoginService {

  @Autowired SystemSettings settings;
  @Autowired CommonService service;
  @Autowired UserRepository usrrepo;
  @Autowired TokenProvider tokenprov;

  @PostConstruct public void init() {
    log.trace("INIT:{}", LoginService.class);
  }

  /** 로그인 수행 */
  public AuthResult lgn01001a01(Login login) throws Exception {
    AuthResult ret = new AuthResult();
    Authentication auth = null;
    String userId = login.getUserId();
    String password = login.getPasswd();

    try {
      /** 평문화 */
      String dpassword = service.aesDecrypt(password);
      /** DB 에서 비교를 위해 재암호화 */
      String epassword = service.dbEncrypt(dpassword);

      /** 비밀번호 노출 우려가 있으므로 실제로는 출력을 제한한다. */
      log.debug("USER-ID:{} / PASSWORD:{} / {} / {}", userId, password, dpassword, epassword);
      User user = usrrepo.findOneByUserIdEquals(userId);
      if (user != null) {
        if (epassword.equals(user.getPasswd())) {
          log.trace("USER:{}", user);
          Collection<SimpleGrantedAuthority> authorities =
            Arrays.stream(
              arr("ROLE_USER"))
              .map(SimpleGrantedAuthority::new)
              .collect(Collectors.toList());
          /**
           * authenticationToken 객체를 통해 Authentication 객체 생성
           * 이 과정에서 CustomUserDetailsService 에서 우리가 재정의한 loadUserByUsername 메서드 호출
           * Authentication auth = authenticationManagerBuilder.getObject().authenticate(authToken);
           */
          auth = new UsernamePasswordAuthenticationToken(userId, dpassword, authorities);
          Map<String, Object> info = tomap(AuthInfo.builder()
            .ipAddr(remoteAddr())
            .userNm(user.getUserNm())
            .build());
          /** 인증 정보를 기준으로 access 토큰 생성 */ 
          String atoken = tokenprov.createToken(TOK_TYP_ACC, auth, info);

          /** 인증 정보를 기준으로 refresh 토큰 생성 */ 
          String rtoken = tokenprov.createToken(TOK_TYP_REF, auth, info);
          ret = AuthResult.builder()
            .userId(userId)
            .userNm(user.getUserNm())
            .accessToken(atoken)
            .refreshToken(rtoken)
            .restyp("")
            .rescd("0000")
            .build();
        } else {
          throw new ApiException(0, "USER_NOT_FOUND");
        }
      } else {
        throw new ApiException(0, "USER_NOT_FOUND");
      }
    } catch (Exception e) {
      if (!(e instanceof ApiException)) {
        log.debug("E:{}", e);
      } else {
        throw e;
      }
    }
    return ret;
  }

  /** 로그인 연장 */
  public AuthResult lgn01002a01() {
    HttpServletRequest req = curRequest();
    String refreshToken = getAuthToken(req);
    AuthResult ret = new AuthResult();
    try {
      /** 리프레시토큰을 받아서 유효하다면 */
      Claims claims = tokenprov.parseToken(TOK_TYP_REF, refreshToken, req);
      Authentication auth = tokenprov.getAuth(claims);
      Object data = claims.get(EXTRA_INFO);
      AuthInfo ainfo = convert(data, new AuthInfo());
      if (auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER"))) {
      }
      /** 시간 연장된 새로운 액세스토큰을 발급하여 리턴한다. */
      String atoken = tokenprov.createToken(TOK_TYP_ACC, auth, data);
      ret = AuthResult.builder()
        .userId(claims.getSubject())
        .userNm(ainfo.getUserNm())
        .accessToken(atoken)
        .restyp("")
        .rescd("0000")
        .build();
    } catch (Exception ignore) {
    }
    return ret;
  }
}

3-3-2. LoginController 구현

  • /src/main/java/my/was/mywas/works/lgn/LoginController.java 파일을 작성한다.

package my.was.mywas.works.lgn;

import static my.was.mywas.commons.RestResponse.response;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.AuthResult;
import my.was.mywas.commons.CommonEntity.Login;

@Slf4j @RestController
@RequestMapping("/api/lgn")
public class LoginController {

  static final String CONTROLLER_TAG = "로그인 API";

  @Autowired LoginService service;

  @PostConstruct public void init() {
    log.trace("INIT:{}", LoginController.class);
  }

  @Operation(summary = "로그인 (lgn01001a01)", tags = { CONTROLLER_TAG })
  @PostMapping("/lgn01001")
  public ResponseEntity<AuthResult> lgn01001a01(@RequestBody Login login) {
    return response(() -> service.lgn01001a01(login));
  }

  @Operation(summary = "로그인연장 (lgn01002a01)", tags = { CONTROLLER_TAG })
  @PostMapping("/lgn01002")
  public ResponseEntity<AuthResult> lgn01002a01() {
    /** 헤더로 입력된 리프레시 토큰을 사용한다 */
    return response(() -> service.lgn01002a01());
  }
}

3-4. 게시물 파트 구현 (atc)

3-4-1. Article 구현

  • /src/main/java/my/was/mywas/works/atc/Article.java 파일을 작성한다.

package my.was.mywas.works.atc;

import java.io.Serializable;
import java.util.Date;

import org.hibernate.annotations.Comment;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Entity
@Table(name = "TB_ARTICLE")
@Schema(title = "게시물 (Article)")
@Comment("게시물 (Article)")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder @ToString
public class Article implements Serializable {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  @Schema(title = "게시글 고유번호")
  private Long id;

  @Column(name = "board_id")
  @Schema(title = "게시판 ID")
  private Long boardId;

  @Column(name = "num", length = 32)
  @Schema(title = "게시글 번호")
  private String num;

  @Column(name = "title", length = 128)
  @Schema(title = "게시글 제목")
  private String title;

  @Column(name = "user_id", length = 32)
  @Schema(title = "글쓴이 ID")
  private String userId;

  @Column(name = "user_nm", length = 32)
  @Schema(title = "글쓴이 이름")
  private String userNm;

  @Column(name = "contents", length = 99999)
  @Schema(title = "내용")
  private String contents;

  @Column(name = "ctime")
  @Schema(title = "생성일시")
  private Date ctime;

  @Column(name = "utime")
  @Schema(title = "수정일시")
  private Date utime;
}

3-4-2. ArticleRepository 구현

  • /src/main/java/my/was/mywas/works/atc/ArticleRepository.java 파일을 작성한다.

package my.was.mywas.works.atc;

import java.util.List;

import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ArticleRepository extends JpaRepository<Article, Long> {

  /** 게시물 상세 조회 (JPA 메소드명에 의한 자동생성) */
  Article findOneByIdEquals(Long id);

  /** 게시물 갯수 조회 (named-query) */
  Integer countArticles(String searchType, String keyword);

  /** 게시물 검색 (named-query) */
  List<Article> searchArticles(String searchType, String keyword, String orderType, Pageable pageable);

  /** 다음 게시물 번호 조회 (named-query) */
  Integer getMaxNumber(Long boardId);

  /** 게시물 검색 ( SQL 질의문 사용 ) */
  @Query(nativeQuery = true)
  List<Article> searchArticlesUsingNative(String searchType, String keyword);
}

  • /src/main/resources/mappings/orm-article.xml ORM 파일을 작성한다.

<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings 
  xmlns="http://java.sun.com/xml/ns/persistence/orm"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm http://java.sun.com/xml/ns/persistence/orm_2_0.xsd"
  version="2.0">
  <package>my.was.mywas.works.atc</package>
  <!-- JPQL 질의 -->
  <named-query name="Article.searchArticles">
    <query>
    <![CDATA[
    select
      new Article(id, boardId, num, title, userId, userNm, '', ctime, utime)
    from
      Article atc
    where
      (:searchType is not null or true) and
      (:searchType != '1' or title like '%' || :keyword || '%') and
      (:searchType != '2' or contents like '%' || :keyword || '%') and
      (:searchType != '3' or (
        title like '%' || :keyword || '%' or
        contents like '%' || :keyword || '%'
      ))
    order by
      (case when :orderType = '1' then utime else null end) asc,
      (case when :orderType = '2' then utime else null end) desc,
      (case when :orderType = '3' then title else null end) asc,
      (case when :orderType = '4' then title else null end) desc,
      (case when :orderType = '5' then id else null end) asc,
      atc.id desc
    ]]>
    </query>
  </named-query>
  <named-query name="Article.countArticles">
    <query>
    <![CDATA[
    select
      count(atc)
    from
      Article atc
    where
      (:searchType is not null or true) and
      (:searchType != '1' or title like '%' || :keyword || '%') and
      (:searchType != '2' or contents like '%' || :keyword || '%') and
      (:searchType != '3' or (
        title like '%' || :keyword || '%' or
        contents like '%' || :keyword || '%'
      ))
    ]]>
    </query>
  </named-query>
  <named-query name="Article.getMaxNumber">
    <query>
    <![CDATA[
    select max(case when n is null then 0 else n end) as max from (
      select
        cast(max(num) as int) as n
      from Article a
      where boardId = :boardId
      union
      select
        cast(count(num) as int) as n
      from Article b
      where boardId = :boardId
    ) a
    ]]>
    </query>
  </named-query>
  <!-- SQL 질의 -->
  <named-native-query name="Article.searchArticlesUsingNative" result-set-mapping="Article">
    <query>
    <![CDATA[
    SELECT
      *
    FROM
      TB_ARTICLE
    WHERE
      (:searchType IS NOT NULL OR TRUE) AND
      (:searchType != '1' OR TITLE LIKE '%' || :keyword || '%') AND
      (:searchType != '2' OR CONTENTS LIKE '%' || :keyword || '%') AND
      (:searchType != '3' OR (
        TITLE LIKE '%' || :keyword || '%' OR
        CONTENTS LIKE '%' || :keyword || '%'
      ))
    ]]>
    </query>
  </named-native-query>
  <sql-result-set-mapping name="Article">
    <entity-result entity-class="my.was.mywas.works.atc.Article"/>
  </sql-result-set-mapping>
</entity-mappings>

3-4-3. ArticleService 구현

package my.was.mywas.works.atc;

import static com.ntiple.commons.ConvertUtil.convert;
import static my.was.mywas.commons.Constants.NOT_PERMITTED_USER;
import static my.was.mywas.commons.Constants.RESCD_OK;

import java.util.Date;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.ApiException;
import my.was.mywas.commons.CommonEntity.AuthDetail;
import my.was.mywas.commons.CommonEntity.Result;
import my.was.mywas.commons.CommonEntity.SearchEntity;

@Slf4j @Service
public class ArticleService {

  @Autowired private ArticleRepository repository;

  @PostConstruct public void init() {
    log.trace("INIT:{}", ArticleService.class);
  }

  /** 게시글 조회 */
  public Article atc01001a02(Long id) throws Exception {
    Article ret = null;
    ret = repository.findOneByIdEquals(id);
    return ret;
  }

  /** 글 검색 */
  public SearchEntity<Article> atc01001a04(SearchEntity<?> prm) throws Exception {
    SearchEntity<Article> ret = convert(prm, new SearchEntity<>());
    Integer total = repository.countArticles(prm.getSearchType(), prm.getKeyword());
    List<Article> list = repository.searchArticles(prm.getSearchType(), prm.getKeyword(),
      prm.getOrderType(), prm.getPageable());
    ret.setRowTotal(total);
    ret.setList(list);
    return ret;
  }

  /** 글 저장 */
  public Result atc01001a01(Article prm) throws Exception {
    Result ret = new Result();
    log.debug("PRM:{}", prm);
    Article article = prm;
    Date date = new Date();
    Long pid = prm.getId();
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    String userId = auth.getName();
    if (pid != null && pid != 0) {
      /** 본인이 쓴 글인지 먼저 확인해야 한다. */
      article = repository.findOneByIdEquals(pid);
      if (article != null && article.getUserId() != null) {
        if (userId == null || !userId.equals(article.getUserId())) {
          throw new ApiException(0, NOT_PERMITTED_USER);
        }
        article.setTitle(prm.getTitle());
        article.setContents(prm.getContents());
        article.setUtime(date);
        repository.save(article);
        ret.setRescd(RESCD_OK);
      }
    } else {
      /** 새글등록 */
      AuthDetail detail = convert(auth.getDetails(), new AuthDetail());
      String num = String.valueOf(repository.getMaxNumber(article.getBoardId()) + 1);
      article.setBoardId(1L);
      article.setNum(num);
      article.setUserId(userId);
      article.setUserNm(detail.getExtraInfo().getUserNm());
      article.setCtime(date);
      article.setUtime(date);
      repository.save(article);
      ret.setRescd(RESCD_OK);
    }
    return ret;
  }

  /** 글 삭제 */
  public Result atc01001a03(Long id) throws Exception {
    Result ret = new Result();
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    String userId = auth.getName();
    Article article = null;
    /** 본인이 쓴 글인지 먼저 확인해야 한다. */
    article = repository.findOneByIdEquals(id);
    if (article != null && article.getUserId() != null) {
      if (userId == null || !userId.equals(article.getUserId())) {
        throw new ApiException(0, NOT_PERMITTED_USER);
      }
      repository.deleteById(id);
    }
    return ret;
  }
}

3-4-4. ArticleController 구현

  • /src/main/java/my/was/mywas/works/atc/ArticleController.java 파일을 작성한다.

package my.was.mywas.works.atc;

import static my.was.mywas.commons.RestResponse.response;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.Result;
import my.was.mywas.commons.CommonEntity.SearchEntity;

@Slf4j @RestController
@RequestMapping("/api/atc")
public class ArticleController {

  static final String CONTROLLER_TAG1 = "게시글 API"; 

  @Autowired ArticleService service;
  @PostConstruct public void init() {
    log.trace("INIT:{}", ArticleController.class);
  }

  @Operation(summary = "게시물 저장/수정 (atc01001a01)", tags = { CONTROLLER_TAG1 })
  @PutMapping("/atc01001")
  public ResponseEntity<Result> atc01001a01(
    @RequestBody Article prm) {
    return response(() -> service.atc01001a01(prm));
  }

  @Operation(summary = "게시글 조회 (atc01001a02)", tags = { CONTROLLER_TAG1 })
  @GetMapping("/atc01001/{id}")
  public ResponseEntity<Article> atc01001a02(
    @PathVariable(value = "id") @Parameter(description = "게시물 고유번호") Long id) {
    return response(() -> service.atc01001a02(id));
  }

  @Operation(summary = "게시글 삭제 (atc01001a03)", tags = { CONTROLLER_TAG1 })
  @DeleteMapping("/atc01001/{id}")
  public ResponseEntity<Result> atc01001a03(
    @PathVariable(value = "id") @Parameter(description = "게시물 고유번호") Long id) {
    return response(() -> service.atc01001a03(id));
  }

  @Operation(summary = "게시글 검색 (atc01001a04)", tags = { CONTROLLER_TAG1 })
  @PostMapping("/atc01001")
  public ResponseEntity<SearchEntity<Article>> atc01001a04(
    @RequestBody SearchEntity<?> prm) {
    return response(() -> service.atc01001a04(prm));
  }
}

4. 마무리

여기까지 하고나면 게시판 백엔드에서 필요한 거의 모든걸 구현했다고 볼 수 있다.

gradlew bootRun 명령으로 실행하고나서 'http://localhost:8080/swagger/swagger-ui/index.html' 페이지를 접속해 보면

아래와 같이 모든 API 가 기재되어 있고, 실행해 볼 수 있는 SWAGGER 페이지가 뜰 것이다


처음 스터디 시작 할 때에는 게시판이라 심플하게 필요한것만 딱 딱 골라서 작성하고 마무리 하려 했는데...

일이 너무 커져버린거 같다... (이정도면 연습용이 아니라 준 프로젝트 급....)

(사실 초반에 하겠다고 했던 기능둘 중에서도 몇 몇은 빠진거임..)

여튼 JAVA 백엔드 기능들까지는 모두 완료 했으니.

다음부터는 프론트엔드 진도 나가보기로 하자!

결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403

github : https://github.com/lupfeliz/study-202403

[ ←전편보기 / 1편, 2편, 4편, 5편, 6편 / 다음편보기→ ]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다