- OS: Alpine Linux 3.16
- Language: openjdk 17
- Framework: SpringBoot 3.1.2
- build & lib management: Gradle 8.1.2
- DB: MySQL 8.0.25
- ORM: Spring Boot JPA
- Etc: lombok, jjwt, flyway
- 실행 전
openjdk-17
, docker
, docker-compose
가 설치되어 있어야 합니다.
# 활성화 할 프로파일
SPRING_PROFILE: local
# DB 유저 정보
DB_PASSWORD: yourpassword1234
# JWT 설정
JWT_SECRET: yoursecretkey1234
JWT_EXPIRE: 600
3-2. 프로젝트 루트 경로에서 build & run 진행
1. start-server.sh 사용하여 build, run 한꺼번에 실행하기
2. start-server.sh 사용하지 않고 실행하기
- build jar
- docker compose up
docker compose -f docker-compose.yml up -d
Health Check URL 을 통해 서버가 정상적으로 실행되었는지 확인
curl -X GET http://127.0.0.1:8080/actuator/health
curl -X GET https://api-wanted-internship.hyoj.me/actuator/health
# 유저 정보 테이블
CREATE TABLE users
(
id bigint NOT NULL PRIMARY KEY AUTO_INCREMENT,
email varchar(50) NOT NULL UNIQUE COMMENT '이메일(로그인 ID)',
password varchar(50) NOT NULL COMMENT '비밀번호(bcrypt)',
created_at datetime COMMENT '생성일자',
updated_at datetime COMMENT '수정일자',
is_deleted tinyint(1) DEFAULT false COMMENT '삭제여부 true/false',
deleted_at datetime DEFAULT NULL COMMENT '삭제일자'
) COMMENT = '유저 정보';
# 게시글 정보 테이블
CREATE TABLE posts
(
id bigint NOT NULL PRIMARY KEY AUTO_INCREMENT,
user_id bigint NOT NULL COMMENT 'users PK',
title varchar(20) NOT NULL COMMENT '게시글 제목',
contents text NOT NULL COMMENT '게시글 내용',
created_at datetime COMMENT '생성일자',
updated_at datetime COMMENT '수정일자',
is_deleted tinyint(1) DEFAULT false COMMENT '삭제여부 true/false',
deleted_at datetime DEFAULT NULL COMMENT '삭제일자',
FOREIGN KEY (user_id) REFERENCES users(id)
) COMMENT = '게시글 정보';
- 각 API 마다 일관된 응답을 제공하기 위해 기본 응답 구조를 정의하였습니다.
- 각 요청마다 트랜잭션 ID를 할당하여 추후 로그 추적을 용이하게 하였습니다.
2. DB 마이그레이션을 위한 flyway 라이브러리 사용
- Spring Boot 환경에서 유연한 DB 변경 이력 관리를 위해 flyway 라이브러리를 도입하였습니다.
src/main/resources/db/migration/V1__init_db.sql
파일을 사용하여 테이블을 자동으로 마이그레이션함으로써 개발자에게 편리하도록 하였습니다.
- 각 에러에 고유한 에러코드를 부여하여 디버깅을 용이하게 하였습니다.
- Spring Actuator를 활용하여
/actuator/health
엔드포인트를 통해 서버 Health Check 기능을 구현하였습니다.
- 프로파일에 따라 Actuator 설정을 다르게 하여 외부에 노출할 URL과 제한할 URL을 구분하였습니다.
- 세션 없이 간편하게 사용자 인증을 수행하기 위해 JWT 를 활용하였습니다.
- JWT 만료 시간을 10분으로 짧게 설정하여 토큰 탈취 시의 위험성을 최소화하였습니다.
- Spring Boot 필터 단에서 JWT 검증을 수행하여 Request 최상단에서 사용자 인증이 되도록 하였습니다.
- 민감한 정보인
password
는 JWT claims에 포함시키지 않고, 사용자 구분을 위한 정보만을 담았습니다(id
, email
).
2. Bcrypt 알고리즘을 사용한 사용자 비밀번호 암호화
- 비밀번호는 복호화되서는 안되는 민감한 정보이므로 단방향 해시 알고리즘인 Bcrypt를 활용하여 암호화하고, 암호화된 비밀번호를 DB에 저장하도록 하였습니다.
- 로그인 시 입력된
password
파라미터를 Bcrypt 암호화하여 DB에 저장된 password
와 대조합니다.
- Spring Boot Validation 을 활용하여 파라미터의 유효성을 검사합니다.
- 유효성 검사 실패 시 Exception Handler 에서
MethodArgumentNotValidException
을 catch 하여 적절한 에러 메시지를 응답에 포함시킵니다.
4. 회원가입 시 중복 이메일 검사 기능 추가
- 회원가입 시 이미 가입된 이메일을 입력할 경우, '
E4090, 이미 가입된 이메일입니다.
' 응답을 반환합니다.
1. Users
와 Posts
Entity 간의 One-to-Many 관계 설정
- 게시글은 한 사용자에 의해 작성되며, 사용자는 여러 개의 게시글을 작성할 수 있기에
Users
와 Posts
사이에 일대다(One-to-Many) 관계를 설정하였습니다.
- Lazy 로딩을 활용하여
Posts
테이블 조회 시 Users
테이블 정보는 필요 시에만 가져오도록 구성하였습니다.
- Request Header의 액세스 토큰 유무를 검사하고, 액세스 토큰이 없는 경우 '
E4010, 로그인 정보를 찾을 수 없습니다.
' 응답을 반환합니다.
3. 게시글 목록 조회 시 Pagination 기능 적용
- Pagination 기능을 적용하여 요청 당 출력되는 데이터 양을 관리하고 적절한 개수의 데이터만을 조회할 수 있도록 하였습니다.
4. 게시글 삭제 시 Soft Delete 처리
- DB 상의 데이터를 영구적으로 삭제하는 Hard Delete 대신 Soft Delete 를 적용하여 추후 데이터 복구가 가능하도록 설계하였습니다.
- 게시글 삭제 시
Posts
테이블의 is_deleted
컬럼을 true
로 변경하고, deleted_at
컬럼을 삭제 시각으로 업데이트하여 삭제 여부를 구분할 수 있도록 하였습니다.
- 액세스 토큰에 포함된 사용자 정보를 활용하여 작업하려는 게시글이 현재 사용자의 것인지 확인합니다.
- 현재 사용자의 게시글이 아닌 경우, '
E4031, 해당 게시물에 대한 권한이 없습니다.
' 응답을 반환합니다.
6. 게시글 상세 조회, 수정, 삭제 시 해당 게시글의 존재 여부 확인
- Path Variables에 포함된
postId
가 존재하지 않는 게시글 id 이거나 이미 삭제된 게시글 id 인 경우, 'E4042, 존재하지 않는 게시글입니다.
' 응답을 반환합니다.
7. 게시글 삭제 API 성공 시의 HTTP 상태 코드를 204
가 아닌 200
으로 처리
204 NO_CONTENT
상태 코드는 응답 본문이 없어 출력되지 않기 때문에, 모든 API의 일관성 있는 응답 구조를 위해 200 OK
로 처리하였습니다.
Request Header
Content-Type
: application/json
email
: 이메일, string
password
: 패스워드, string
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
data
: 응답 데이터 객체, object
id
: 생성된 유저 PK, long
email
: 생성된 유저 이메일, string
{
"txid": "558ca027-1248-4a63-935f-b3523153812a",
"status": 201,
"message": "정상적으로 처리되었습니다.",
"data": {
"id": 1,
"email": "[email protected]"
}
}
curl --location 'https://api-wanted-internship.hyoj.me/api/v1/users/join' \
--header 'Content-Type: application/json' \
--data '{
"email": "[email protected]",
"password": "test1234"
}'
Request Header
Content-Type
: application/json
email
: 이메일, string
password
: 패스워드, string
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
data
: 응답 데이터 객체, object
accessToken
: 유저 액세스 토큰, string
userInfo
: 유저 정보 객체, object
id
: 로그인한 유저 PK, long
email
: 로그인한 유저 이메일, string
{
"txid": "46fac5d6-7279-4bf2-9140-8d35dc160e24",
"status": 200,
"message": "정상적으로 처리되었습니다.",
"data": {
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6MSwiZW1haWwiOiJ0ZXN0QGFiYy5jb20iLCJpYXQiOjE2OTE5Mjg5MDQsImV4cCI6MTY5MTkyOTUwNH0.gKkxmJkx_ExqzsYYhrCEm7be36W9ZeDqWPIoKTrGVwJTBcHZ60KoshGe5HaY6InuuMivUMPg5KkbdjVGeUTruw",
"userInfo": {
"id": 1,
"email": "[email protected]"
}
}
}
curl --location 'https://api-wanted-internship.hyoj.me/api/v1/users/login' \
--header 'Content-Type: application/json' \
--data '{
"email": "[email protected]",
"password": "test1234"
}'
Request Header
Content-Type
: application/json
Authorization
: Bearer Token, string
title
: 게시글 제목, string
contents
: 게시글 내용, string
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
data
: 응답 데이터 객체, object
id
: 생성된 게시글 PK, long
author
: 게시글 작성자, string
createdAt
: 생성 시각(yyyy-MM-dd HH-mm-ss), string
updatedAt
: 수정 시각(yyyy-MM-dd HH-mm-ss), string
isUpdated
: 수정 여부, boolean
title
: 생성된 게시글 제목, string
contents
: 생성된 게시글 내용, string
{
"title": "test title",
"contents": "test contents~~~"
}
{
"txid": "bd8ba5a6-bb13-4329-9edf-f45a15da1554",
"status": 201,
"message": "정상적으로 처리되었습니다.",
"data": {
"id": 72,
"author": "[email protected]",
"createdAt": "2023-08-14 17:22:48",
"updatedAt": "2023-08-14 17:22:48",
"isUpdated": false,
"title": "test title",
"contents": "test contents~~~"
}
}
curl --location 'https://api-wanted-internship.hyoj.me/api/v1/posts' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer xxxx' \
--data '{
"title": "test title",
"contents": "test contents~~~"
}'
Request Header
Content-Type
: application/json
Authorization
: Bearer Token, string
page
: 페이지 번호 (1부터 시작), integer
size
: 페이지 당 표시할 게시글 수, integer
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
data
: 응답 데이터 객체, object
content
: 조회된 게시글 목록, array
id
: 게시글 PK, long
author
: 게시글 작성자, string
createdAt
: 생성 시각(yyyy-MM-dd HH-mm-ss), string
isUpdated
: 수정 여부, boolean
title
: 게시글 제목, string
pageable
: 페이징 객체, object
{
"txid": "dbf43fd4-1b36-4f2c-ac5a-e1f19546d318",
"status": 200,
"message": "정상적으로 처리되었습니다.",
"data": {
"content": [
{
"id": 72,
"author": "[email protected]",
"createdAt": "2023-08-14 17:22:48",
"isUpdated": false,
"title": "test title"
}
],
"pageable": {
"sort": {
"empty": true,
"sorted": false,
"unsorted": true
},
"offset": 0,
"pageNumber": 0,
"pageSize": 1,
"paged": true,
"unpaged": false
},
"last": false,
"totalPages": 10,
"totalElements": 10,
"size": 1,
"number": 0,
"sort": {
"empty": true,
"sorted": false,
"unsorted": true
},
"first": true,
"numberOfElements": 1,
"empty": false
}
}
curl --location 'https://api-wanted-internship.hyoj.me/api/v1/posts?page=1&size=1' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer xxxx'
Request Header
Content-Type
: application/json
Authorization
: Bearer Token, string
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
data
: 응답 데이터 객체, object
id
: 게시글 PK, long
author
: 작성자, string
createdAt
: 생성 시각(yyyy-MM-dd HH-mm-ss), string
updatedAt
: 수정 시각(yyyy-MM-dd HH-mm-ss), string
isUpdated
: 수정 여부, boolean
title
: 게시글 제목, string
contents
: 게시글 내용, string
{
"txid": "269c279e-1822-4733-ba68-5e26c2150ba9",
"status": 200,
"message": "정상적으로 처리되었습니다.",
"data": {
"id": 72,
"author": "[email protected]",
"createdAt": "2023-08-14 17:22:48",
"updatedAt": "2023-08-14 17:22:48",
"isUpdated": false,
"title": "test title",
"contents": "test contents~~~"
}
}
curl --location 'https://api-wanted-internship.hyoj.me/api/v1/posts/72' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer xxxx'
Request Header
Content-Type
: application/json
Authorization
: Bearer Token, string
title
: 게시글 제목, string
contents
: 게시글 내용, string
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
data
: 응답 데이터 객체, object
id
: 수정된 게시글 PK, long
author
: 작성자, string
createdAt
: 생성 시각(yyyy-MM-dd HH-mm-ss), string
updatedAt
: 수정 시각(yyyy-MM-dd HH-mm-ss), string
isUpdated
: 수정 여부, boolean
title
: 수정된 게시글 제목, string
contents
: 수정된 게시글 내용, string
{
"title": "edited title",
"contents": "edited contents~~~"
}
{
"txid": "bd8ba5a6-bb13-4329-9edf-f45a15da1554",
"status": 200,
"message": "정상적으로 처리되었습니다.",
"data": {
"id": 72,
"author": "[email protected]",
"createdAt": "2023-08-14 17:22:48",
"updatedAt": "2023-08-15 02:28:51",
"isUpdated": true,
"title": "edited title",
"contents": "edited contents~~~"
}
}
curl --location --request PUT 'https://api-wanted-internship.hyoj.me/api/v1/posts/72' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer xxxx' \
--data '{
"title": "edited title",
"contents": "edited contents~~~"
}'
Request Header
Content-Type
: application/json
Authorization
: Bearer Token, string
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
data
: 응답 데이터 객체, object
id
: 삭제된 게시글 PK, long
author
: 작성자, string
createdAt
: 생성 시각(yyyy-MM-dd HH-mm-ss), string
updatedAt
: 수정 시각(yyyy-MM-dd HH-mm-ss), string
isUpdated
: 수정 여부, boolean
title
: 삭제된 게시글 제목, string
contents
: 삭제된 게시글 내용, string
{
"txid": "bd8ba5a6-bb13-4329-9edf-f45a15da1554",
"status": 200,
"message": "정상적으로 처리되었습니다.",
"data": {
"id": 72,
"author": "[email protected]",
"createdAt": "2023-08-14 17:22:48",
"updatedAt": "2023-08-15 02:28:51",
"isUpdated": true,
"title": "edited title",
"contents": "edited contents~~~"
}
}
curl --location --request DELETE 'https://api-wanted-internship.hyoj.me/api/v1/posts/72' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer xxxx'
에러 코드 |
에러 이름 |
상태 코드 |
에러 설명 |
E4000 |
CLIENT_ERROR |
400 |
잘못된 요청 파라미터 |
E4010 |
UNAUTHORIZED |
401 |
로그인 정보가 없음 |
E4011 |
TOKEN_NOT_FOUND |
401 |
토큰을 찾을 수 없음 |
E4012 |
TOKEN_INVALID |
401 |
유효하지 않은 토큰 |
E4013 |
TOKEN_DATE_EXPIRED |
401 |
토큰이 만료됨 |
E4014 |
LOGIN_FAILED |
401 |
로그인 정보가 잘못됨 |
E4030 |
FORBIDDEN |
403 |
권한 없음 |
E4031 |
NOT_MY_POST |
403 |
해당 게시글에 대한 작업 권한 없음 |
E4040 |
NOT_FOUND |
404 |
존재하지 않는 데이터 또는 경로 |
E4041 |
USER_NOT_FOUND |
404 |
존재하지 않는 유저 |
E4042 |
POST_NOT_FOUND |
404 |
존재하지 않는 게시글 |
E4090 |
ALREADY_SIGNED_UP |
409 |
이미 가입된 이메일 |
E4220 |
INVALID_FORMAT |
422 |
파라미터 유효성 검사 실패 |
E9000 |
INTERNAL_SERVER_ERROR |
500 |
서버 에러 |
E9100 |
DATABASE_ACCESS_ERROR |
500 |
DB 에러 |
E9200 |
TOKEN_CREATED_FAILED |
500 |
토큰 생성 중 에러 |
txid
: 요청 트랜잭션 id, string
status
: http 상태 코드, integer
message
: 응답 메시지, string
error
: 에러 데이터 객체, object
code
: 에러 코드, string
description
: 에러 이름(설명), string
{
"txid": "054871bd-32a1-4b4a-916e-fac786077d8a",
"status": 401,
"message": "로그인 정보가 일치하지 않습니다.",
"error": {
"code": "E4014",
"description": "LOGIN_FAILED"
}
}
- EC2 인스턴스 상에서 Docker Compose를 활용하여 API 서버와 MySQL 컨테이너를 실행시켰습니다.
- EC2 인스턴스를 종료시킨 후 재시작하여도 서버 IP가 변경되지 않도록 하기 위해 Elastic IP를 활용하여 서버 IP를 고정하였습니다.
- Cloudflare DNS 를 사용하여 개인 도메인
hyoj.me
에 EC2 인스턴스 주소를 연결하였습니다.
- Certbot 을 활용하여 SSL 인증서를 발급받고, 이를 Nginx 에 적용하여 https 요청이 가능하도록 하였습니다.
- 보안을 강화하기 위해 EC2 인스턴스의 인바운드 포트는
80
과 443
만 허용하였으며, Nginx 를 사용하여 API 서버 포트 8080
에 연결되도록 하였습니다.
- 만약
80
포트로 들어오는 http 요청이 발생하면, 이를 443
포트의 https 요청으로 리다이렉트시켜 서버 접속이 https 프로토콜로만 이루어지도록 설정하였습니다.