Spring Boot와 MyBatis를 활용한 유저 API 구현
이번 글에서는 Spring Boot와 MyBatis를 사용해 유저(User) API를 구현하는 방법을 설명합니다. 이 API는 회원가입, 로그인, 유저 정보 조회, 비밀번호 변경, 회원 탈퇴와 같은 기능을 제공하며, HTTP 요청을 처리하여 유저 관련 데이터를 관리하는 RESTful API로 설계되었습니다.
1. 프로젝트 개요
이 프로젝트의 목적은 다음과 같은 유저 관련 기능을 제공하는 것입니다:
- 회원가입: 새로운 유저 등록.
- 로그인: 유저 인증.
- 유저 정보 조회: 로그인된 유저 정보 반환.
- 비밀번호 변경: 기존 비밀번호를 검증 후 변경.
- 회원 탈퇴: 유저 계정 삭제.
이러한 기능을 구현하기 위해 Controller, Service, Mapper 클래스들이 사용됩니다. 각 레이어는 역할에 맞게 데이터를 처리하며, MyBatis를 통해 데이터베이스와 상호작용합니다.
2. Controller 클래스: UserController
UserController
는 유저 API의 엔드포인트를 정의하는 클래스입니다. 클라이언트의 요청을 처리하고, Service 레이어를 통해 필요한 작업을 수행합니다.
package com.example.boardserver.controller;
import com.example.boardserver.aop.LoginCheck;
import com.example.boardserver.dto.CommonResponse;
import com.example.boardserver.dto.UserDTO;
import com.example.boardserver.dto.request.UserLoginRequest;
import com.example.boardserver.dto.request.UserUpdatePasswordRequest;
import com.example.boardserver.dto.response.LoginResponse;
import com.example.boardserver.dto.response.UserInfoResponse;
import com.example.boardserver.service.UserService;
import com.example.boardserver.utils.SessionUtil;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// 회원가입
@PostMapping("/sign-up")
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<CommonResponse<Void>> signUp(@RequestBody UserDTO userDTO) {
if (UserDTO.hasNullDataBeforeRegister(userDTO)) {
throw new RuntimeException("회원 가입 정보를 확인해주세요");
}
userService.register(userDTO);
return createResponse(HttpStatus.CREATED, "USER_REGISTERED", "회원 가입이 성공적으로 완료되었습니다.", null);
}
// 로그인
@PostMapping("/sign-in")
public ResponseEntity<CommonResponse<LoginResponse>> login(
@RequestBody UserLoginRequest loginRequest,
HttpSession session
) {
String userId = loginRequest.getUserId();
String password = loginRequest.getPassword();
UserDTO userInfo = userService.login(userId, password);
if (userInfo == null) {
return createResponse(HttpStatus.NOT_FOUND, "LOGIN_FAILED", "아이디 또는 비밀번호가 잘못되었습니다.", LoginResponse.getFailResponse());
}
String id = String.valueOf(userInfo.getId());
if (userInfo.getStatus() == UserDTO.Status.ADMIN) {
SessionUtil.setLoginAdminId(session, id);
} else {
SessionUtil.setLoginMemberId(session, id);
}
return createResponse(HttpStatus.OK, "LOGIN_SUCCESS", "로그인이 성공적으로 완료되었습니다.", LoginResponse.success(userInfo));
}
// 유저 정보 조회
@GetMapping("/my-info")
@LoginCheck(roles = {"DEFAULT", "ADMIN"})
public ResponseEntity<CommonResponse<UserInfoResponse>> memberInfo(HttpSession session) {
UserDTO memberInfo = getAuthenticatedUser(session);
if (memberInfo == null) {
return handleUnauthorized();
}
return createResponse(HttpStatus.OK, "USER_INFO_RETRIEVED", "회원 정보 조회가 성공적으로 완료되었습니다.", new UserInfoResponse(memberInfo));
}
// 비밀번호 변경
@PatchMapping("/password")
@LoginCheck(roles = {"DEFAULT", "ADMIN"})
public ResponseEntity<CommonResponse<LoginResponse>> changePassword(
@RequestBody UserUpdatePasswordRequest userUpdatePasswordRequest,
HttpSession session
) {
UserDTO memberInfo = getAuthenticatedUser(session);
if (memberInfo == null) {
return handleUnauthorized();
}
String beforePassword = userUpdatePasswordRequest.getBeforePassword();
String afterPassword = userUpdatePasswordRequest.getAfterPassword();
userService.updatePassword(memberInfo.getId(), beforePassword, afterPassword);
UserDTO userInfo = userService.login(memberInfo.getUserId(), afterPassword);
if (userInfo == null) {
return createResponse(HttpStatus.BAD_REQUEST, "PASSWORD_CHANGE_FAILED", "비밀번호 변경에 실패했습니다.", LoginResponse.getFailResponse());
}
return createResponse(HttpStatus.OK, "PASSWORD_CHANGED", "비밀번호가 성공적으로 변경되었습니다.", LoginResponse.success(userInfo));
}
// 회원 탈퇴
@DeleteMapping("")
@LoginCheck(roles = {"DEFAULT", "ADMIN"})
public ResponseEntity<CommonResponse<LoginResponse>> deleteId(
@RequestBody UserLoginRequest requestBody,
HttpSession session
) {
UserDTO memberInfo = getAuthenticatedUser(session);
if (memberInfo == null) {
return handleUnauthorized();
}
userService.deleteId(memberInfo.getId(), requestBody.getPassword());
return createResponse(HttpStatus.OK, "USER_DELETED", "회원 탈퇴가 성공적으로 완료되었습니다.", LoginResponse.success(null));
}
// 로그아웃
@PutMapping("/logout")
public ResponseEntity<CommonResponse<Void>> logout(HttpSession session) {
SessionUtil.clear(session);
return createResponse(HttpStatus.OK, "LOGOUT_SUCCESS", "로그아웃이 성공적으로 완료되었습니다.", null);
}
// 공통 응답 생성 메서드
private <T> ResponseEntity<CommonResponse<T>> createResponse(HttpStatus status, String code, String message, T data) {
return ResponseEntity.status(status)
.body(new CommonResponse<>(status, code, message, data));
}
// 인증된 사용자 정보 가져오기
private UserDTO getAuthenticatedUser(HttpSession session) {
String userId = SessionUtil.getLoginMemberId(session);
if (userId == null) {
userId = SessionUtil.getLoginAdminId(session);
}
return userService.getUserInfo(userId);
}
// 인증되지 않은 사용자에 대한 처리
private <T> ResponseEntity<CommonResponse<T>> handleUnauthorized() {
return createResponse(HttpStatus.UNAUTHORIZED, "AUTH_ERROR", "인증되지 않은 사용자입니다.", null);
}
}
주요 기능 설명:
- 회원가입: POST 요청을 통해 새로운 사용자를 등록.
- 로그인: POST 요청으로 사용자 인증.
- 유저 정보 조회: GET 요청으로 로그인된 사용자의 정보를 반환.
- 비밀번호 변경: PATCH 요청으로 기존 비밀번호를 검증 후 새로운 비밀번호로 업데이트.
- 회원 탈퇴: DELETE 요청으로 사용자의 계정을 삭제.
3. DTO 및 요청 객체
UserDTO
UserDTO
는 사용자 정보를 담는 데이터 전송 객체입니다. 회원가입 시 필수 정보인 userId
, password
, nickName
등이 포함됩니다.
@Getter
@Setter
@ToString
public class UserDTO {
public enum Status {
DEFAULT, ADMIN, DELETED
}
private Long id;
private String userId;
private String password;
private String nickName;
private Status status;
}
UserLoginRequest
로그인 요청 시 사용되는 데이터 구조입니다.
@Setter
@Getter
public class UserLoginRequest {
@NonNull
private String userId;
@NonNull
private String password;
}
UserUpdatePasswordRequest
비밀번호 변경 시 사용되는 요청 데이터입니다.
@Setter
@Getter
public class UserUpdatePasswordRequest {
@NonNull
private String beforePassword;
@NonNull
private String afterPassword;
}
4. UserService와 구현
UserService
는 비즈니스 로직을 처리하는 서비스 계층입니다. 데이터베이스 연동을 위해 UserProfileMapper
를 사용합니다.
public interface UserService {
void register(UserDTO userProfile);
UserDTO login(String userId, String password);
void updatePassword(Long id, String beforePassword, String afterPassword);
void deleteId(Long id, String password);
UserDTO getUserInfo(String id);
}
5. UserProfileMapper와 MyBatis 설정
UserProfileMapper
는 MyBatis를 사용해 데이터베이스와 상호작용합니다.
@Mapper
public interface UserProfileMapper {
UserDTO findByUserIdAndPassword(@Param("userId") String userId, @Param("password") String password);
int register(UserDTO userDTO);
int updatePassword(UserDTO userDTO);
void deleteUserProfile(@Param("userId") String userId);
UserDTO getUserProfile(@Param("id") String id);
int idCheck(@Param("userId") String userId);
}
@Param
어노테이션은 MyBatis에서 SQL 매퍼(XML 파일 또는 애노테이션 기반 쿼리)와 메서드의 매개변수를 매핑하기 위해 사용됩니다. @Param
을 사용하는 것과 사용하지 않는 것 사이의 차이는 매개변수를 SQL에서 어떻게 참조할 수 있는지에 영향을 미칩니다.
@Param
어노테이션 사용 이유
MyBatis에서 매개변수를 XML 또는 애노테이션 기반 SQL 쿼리에서 참조할 때, 메서드 매개변수가 여러 개일 경우 또는 매개변수 이름을 명시적으로 지정하고 싶을 때 @Param
을 사용합니다. 이를 통해 쿼리에서 매개변수의 이름을 명확하게 지정할 수 있습니다.
@Param
어노테이션의 사용과 미사용 차이
1. 단일 매개변수일 때
단일 매개변수일 경우, MyBatis는 해당 매개변수를 #{parameter}
또는 #{0}
로 접근할 수 있습니다. 이때는 @Param
이 필요하지 않으며, 직접적으로 매개변수를 참조할 수 있습니다.
// @Param 없이 단일 매개변수 사용
UserDTO getUserProfile(String id);
<!-- XML에서 단일 매개변수 참조 -->
<select id="getUserProfile" resultType="com.example.boardserver.dto.UserDTO">
SELECT * FROM user WHERE id = #{id}
</select>
이 경우 MyBatis는 자동으로 #{id}
를 단일 매개변수로 매핑합니다.
2. 여러 매개변수일 때
매개변수가 여러 개일 때는 @Param
을 사용하여 각 매개변수에 대해 SQL에서 명확하게 식별할 수 있는 이름을 지정하는 것이 좋습니다. 그렇지 않으면 MyBatis는 매개변수에 param1
, param2
등의 이름을 자동으로 할당합니다.
// @Param 사용하여 명시적으로 매개변수 이름 지정
UserDTO findByIdAndPassword(@Param("id") Long id, @Param("password") String password);
<!-- XML에서 명시적으로 지정한 매개변수 이름을 사용 -->
<select id="findByIdAndPassword" resultType="com.example.boardserver.dto.UserDTO">
SELECT * FROM user WHERE id = #{id} AND passWord = #{password}
</select>
여기서 @Param("id")
와 @Param("password")
를 사용함으로써, SQL에서 #{id}
와 #{password}
로 매개변수를 명확하게 참조할 수 있습니다.
3. 여러 매개변수에서 @Param을 사용하지 않을 경우
만약 @Param
을 사용하지 않고 여러 매개변수를 사용할 경우, MyBatis는 자동으로 매개변수에 param1
, param2
등의 이름을 할당합니다. 따라서 XML 파일에서 쿼리 작성 시 이러한 이름을 사용해야 합니다.
// @Param 없이 여러 매개변수 사용
UserDTO findByIdAndPassword(Long id, String password);
<!-- 매개변수 이름을 param1, param2로 자동 할당 -->
<select id="findByIdAndPassword" resultType="com.example.boardserver.dto.UserDTO">
SELECT * FROM user WHERE id = #{param1} AND passWord = #{param2}
</select>
이 방식은 직관적이지 않고, 코드 유지보수 측면에서 혼란을 초래할 수 있습니다. 따라서 여러 매개변수를 사용할 때는 @Param
을 사용하여 명시적으로 이름을 지정하는 것이 더 나은 방법입니다.
정리
- 단일 매개변수:
@Param
이 없어도 MyBatis가 자동으로 매개변수를 처리할 수 있습니다. - 여러 매개변수:
@Param
을 사용하지 않으면 MyBatis는param1
,param2
등으로 매개변수를 참조해야 하므로 명시적으로@Param
을 사용하는 것이 좋습니다. @Param
을 사용하는 경우: SQL 쿼리에서 매개변수 이름을 명시적으로 지정할 수 있어 코드 가독성이 향상되고 유지보수가 용이합니다.
6. 비밀번호 암호화: SHA256Util
public class SHA256Util {
public static final String ENCRYPTION_TYPE = "SHA-256";
public static String encryptSHA256(String str) {
String SHA = null;
MessageDigest sh;
try {
// 1. SHA-256 알고리즘을 사용하는 MessageDigest 객체 생성
sh = MessageDigest.getInstance(ENCRYPTION_TYPE);
// 2. 입력된 문자열을 바이트 배열로 변환하여 해시 값 생성
sh.update(str.getBytes());
// 3. 해시 값(바이트 배열)을 받아서 처리
byte[] byteData = sh.digest();
// 4. 바이트 배열을 16진수 문자열로 변환
StringBuilder sb = new StringBuilder();
for (int i = 0; i < byteData.length; i++) {
sb.append(Integer.toString((byteData[i] & 0xff) + 0x100, 16).substring(1));
}
// 5. 최종적으로 생성된 16진수 문자열을 반환
SHA = sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("암호화 에러: ", e);
}
return SHA;
}
}
주요 개념 및 단계별 설명:
1. MessageDigest.getInstance(ENCRYPTION_TYPE)
MessageDigest
는 암호화 해시 함수로, 특정 알고리즘(SHA-256 등)을 사용하여 데이터를 해시하는 기능을 제공합니다.
sh = MessageDigest.getInstance(ENCRYPTION_TYPE);
- 이 줄에서
SHA-256
알고리즘을 사용하여 해시 작업을 수행하는MessageDigest
객체를 생성합니다. 이 객체는 문자열을 입력받아SHA-256
알고리즘에 따라 암호화된 해시 값을 생성합니다. ENCRYPTION_TYPE
이"SHA-256"
로 정의되어 있기 때문에, 이 메서드는SHA-256
알고리즘을 사용합니다.
2. sh.update(str.getBytes())
sh.update(str.getBytes());
update()
메서드는 해시를 수행할 데이터를byte[]
형태로 입력받습니다. 여기서는 입력 문자열str
을getBytes()
메서드를 사용해 바이트 배열로 변환한 후, 이를 해시할 데이터로 전달합니다.getBytes()
메서드는 문자열을 바이트 배열로 변환합니다. 이는SHA-256
알고리즘이 바이트 단위로 데이터를 처리하기 때문에 필요합니다.
3. sh.digest()
byte[] byteData = sh.digest();
digest()
메서드는SHA-256
알고리즘으로 암호화된 결과를 반환합니다. 이 메서드는 내부적으로 해시 연산을 수행하고, 그 결과를 바이트 배열(byte array) 형태로 리턴합니다.SHA-256
해시는 256비트(32바이트) 길이의 해시 값을 생성하므로,byteData
배열은 32바이트 길이를 가집니다.
4. 바이트 배열을 16진수 문자열로 변환
StringBuilder sb = new StringBuilder();
for (int i = 0; i < byteData.length; i++) {
sb.append(Integer.toString((byteData[i] & 0xff) + 0x100, 16).substring(1));
}
- 이 부분은 해시 결과로 나온 바이트 배열을 16진수 문자열로 변환하는 과정입니다. 해시 값은 기본적으로 바이트 배열이므로, 이를 사람이 읽을 수 있는 16진수로 변환해야 합니다.
byteData[i] & 0xff
: 바이트 값을0xff
와 비트 연산하여, 음수로 변환되는 것을 방지하고, 양수로 변환합니다. 이는 바이트 값을 8비트의 범위(0~255)로 유지하기 위한 과정입니다.Integer.toString((byteData[i] & 0xff) + 0x100, 16)
: 바이트 값을 16진수로 변환합니다. 여기서0x100
을 더하는 이유는 한 자리 16진수 값도 항상 두 자리로 맞추기 위함입니다. 즉, 16진수 표현에서 값이9
이하면 한 자리로 출력될 수 있는데, 이를 방지하기 위해0x100
을 더해 항상 두 자리가 되도록 합니다. 그런 다음, 첫 번째 문자를 잘라내고 나머지를 취하는 방식으로 두 자리 16진수 문자열을 얻습니다..substring(1)
:0x100
을 더해 세 자리가 된 문자열에서, 첫 번째 자리는 제거하여 항상 두 자리가 되도록 합니다.
- 이 변환 과정을 반복하여, 바이트 배열의 각 요소를 16진수로 변환하고
StringBuilder
에 추가합니다.
5. 최종 결과 반환
SHA = sb.toString();
StringBuilder
에 저장된 16진수 문자열을 하나의 문자열로 반환합니다. 이 문자열이 암호화된 최종 해시 값입니다.
6. 예외 처리
catch (NoSuchAlgorithmException e) {
throw new RuntimeException("암호화 에러: ", e);
}
- 만약
SHA-256
알고리즘이 시스템에서 지원되지 않는 경우,NoSuchAlgorithmException
이 발생할 수 있으며, 이 예외를 처리하여 프로그램이 중단되지 않도록 합니다. 이때는 런타임 예외로 감싸서 오류 메시지를 출력합니다.
출력 예시
입력 문자열이 "password"
인 경우, SHA-256
으로 암호화된 결과는 다음과 같이 16진수로 출력됩니다:
5e884898da28047151d0e56f8dc6292773603d0d6aabbdd1590b0719a94d7e1e
요약
- SHA-256 해시 알고리즘 사용: 이 클래스는 입력된 문자열을
SHA-256
알고리즘으로 해시합니다. - 16진수 변환: 해시된 바이트 배열을 반복문을 통해 16진수 문자열로 변환합니다.
- 결과: 최종적으로 반환되는 값은 64자리의 16진수 문자열입니다.
이 클래스는 SHA-256
해시 알고리즘을 사용하여 입력 문자열을 안전하게 암호화하며, 변환 과정에서 직접 바이트 배열을 다루어 16진수 문자열로 변환하는 방식입니다.
7. UserProfileMapper.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="com.example.boardserver.mapper.UserProfileMapper">
<select id="getUserProfile" resultType="com.example.boardserver.dto.UserDTO">
SELECT id, userId, password, nickName, createTime, isWithDraw, status
FROM user WHERE id = #{id} </select>
<insert id="insertUserProfile" parameterType="com.example.boardserver.dto.UserDTO">
INSERT INTO user (userId, passWord, nickName, isWithDraw, status, isAdmin)
VALUES (#{userId}, #{password}, #{nickName}, #{isWithDraw}, #{status}, #{isAdmin}) </insert>
<update id="updateUserProfile" parameterType="com.example.boardserver.dto.UserDTO">
UPDATE user
SET password=#{password},
nickName=#{nickName},
isWithDraw=#{isWithDraw},
status=#{status}
WHERE id = #{id}</update>
<delete id="deleteUserProfile">
DELETE
FROM user
WHERE userId = #{userId}
</delete>
<insert id="register" parameterType="com.example.boardserver.dto.UserDTO">
INSERT INTO user (userId, passWord, nickName, createTime, isWithDraw, status)
VALUES (#{userId}, #{password}, #{nickName}, #{createTime}, #{isWithDraw}, #{status})
</insert>
<select id="findByIdAndPassword" resultType="com.example.boardserver.dto.UserDTO">
SELECT id,
userId,
passWord,
nickName,
createTime,
isWithDraw,
status
FROM user
WHERE id = #{id}
AND passWord = #{password}
AND status != 'DELETE'
</select>
<select id="findByUserIdAndPassword" resultType="com.example.boardserver.dto.UserDTO">
SELECT id,
userId,
passWord,
nickName,
createTime,
isWithDraw,
status
FROM user
WHERE userId = #{userId}
AND passWord = #{password}
AND status != 'DELETE'
</select>
<select id="idCheck" resultType="int">
SELECT COUNT(id)
FROM user
WHERE userId = #{userId}
</select>
<update id="updatePassword" parameterType="com.example.boardserver.dto.UserDTO">
UPDATE user
SET passWord = #{password}
WHERE userId = #{userId}
</update>
</mapper>
8. 결론
이번 글에서는 유저 API 구현을 위한 Spring Boot와 MyBatis의 통합 과정에 대해 설명했습니다. 이 API는 회원가입, 로그인, 비밀번호 변경, 유저 정보 조회, 회원 탈퇴 등의 기능을 제공하는 구조로 설계되었습니다.
'프레임워크 > 자바 스프링' 카테고리의 다른 글
대규모 트래픽 게시판 구축 시리즈 #8: 카테고리 API (0) | 2024.09.07 |
---|---|
대규모 트래픽 게시판 구축 시리즈 #7: Spring AOP를 활용한 인증 및 인가 (0) | 2024.09.05 |
대규모 트래픽 게시판 구축 시리즈 #5: MySQL 데이터베이스 연결 설정 (1) | 2024.09.05 |
대규모 트래픽 게시판 구축 시리즈 #4: 프로젝트 초기 셋업 (3) | 2024.09.05 |
대규모 트래픽 게시판 구축 시리즈 #3: 도커를 이용한 데이터베이스 구축 (5) | 2024.09.05 |