대규모 트래픽 게시판 구축 시리즈 #6: 유저 API

2024. 9. 5. 15:15·프레임워크/자바 스프링

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  (1) 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
'프레임워크/자바 스프링' 카테고리의 다른 글
  • 대규모 트래픽 게시판 구축 시리즈 #8: 카테고리 API
  • 대규모 트래픽 게시판 구축 시리즈 #7: Spring AOP를 활용한 인증 및 인가
  • 대규모 트래픽 게시판 구축 시리즈 #5: MySQL 데이터베이스 연결 설정
  • 대규모 트래픽 게시판 구축 시리즈 #4: 프로젝트 초기 셋업
hyeseong-dev
hyeseong-dev
안녕하세요. 백엔드 개발자 이혜성입니다.
  • hyeseong-dev
    어제 오늘 그리고 내일
    hyeseong-dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (286)
      • 여러가지 (107)
        • 알고리즘 & 자료구조 (72)
        • 오류 (4)
        • 이것저것 (29)
        • 일기 (1)
      • 프레임워크 (39)
        • 자바 스프링 (39)
        • React Native (0)
      • 프로그래밍 언어 (38)
        • 파이썬 (30)
        • 자바 (3)
        • 스프링부트 (5)
      • 컴퓨터 구조와 운영체제 (3)
      • DB (17)
        • SQL (0)
        • Redis (17)
      • 클라우드 컴퓨팅 (2)
        • 도커 (2)
        • AWS (0)
      • 스케쥴 (65)
        • 세미나 (0)
        • 수료 (0)
        • 스터디 (24)
        • 시험 (41)
      • 트러블슈팅 (1)
      • 자격증 (0)
        • 정보처리기사 (0)
      • 재태크 (5)
        • 암호화폐 (5)
        • 기타 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    DP
    취업리부트
    Spring Boot
    파이썬
    시험
    celery
    docker
    java
    mybatis
    EC2
    RDS
    ecs
    OOP
    Docker-compose
    Spring WebFlux
    FastAPI
    Redis
    Python
    AWS
    reactor
    spring
    WebFlux
    프로그래머스
    완전탐색
    #개발자포트폴리오 #개발자이력서 #개발자취업 #개발자취준 #코딩테스트 #항해99 #취리코 #취업리부트코스
    그리디
    백준
    자바
    항해99
    SAA
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
hyeseong-dev
대규모 트래픽 게시판 구축 시리즈 #6: 유저 API
상단으로

티스토리툴바