우선, 들어가기에 앞서 이 글은 친절하게 생성하는 방법? 까지 설명되어 있지는 않다
어떤 순서로 무엇을 생성했고 어떤 에러가 있었고 어떻게 조치 했는지 경험적인 요소만이 다수 들어가 있음을 알린다
그리고 AWS와 Travis 연동 예제들을 보면 암복호화가 없던데 이걸 추가해보느라 꽤 애먹었기에
그나마 이 경험이 다른 사람들에게 도움이 되지 않을까 싶어 올려본다
맨 아래에 적어놓은 참고 사이트를 기준으로 해보되 암호화 중에 뭔가 안되면 참고용도로 이 페이지를 보면 될것 같다
개발환경
dev tool : VS Code
JDK version : 11
front-end : Thymeleaf
back-end : Spring Boot 2.5.2 + Spring Data JPA
build : Gradle 7.0.2
database : MySQL
Version Control System : Git
Cloud : AWS ( EC2 free tier, Ubuntu 16.04 )
CI : Git Push → Travis CI → build Test
CD : Travis CI → AWS IAM → AWS 버킷 → AWS CodeDeploy
AWS 생성- CI(Travis) - CD(AWS CodeDeploy)
AWS EC2 free tier를 통해 인스턴스 생성 ( Ubuntu 16.04 )
포트 번호에 따른 접근을 허용하기 위해 보안 그룹을 생성 → 3306
22 : SSH
** ssh : 컴퓨터와 컴퓨터가 네트워크 상에서 통신을 할 때 보안적으로 안전하게 통신하기 위해 사용하는 프로토콜, 두 개의 public/private 키를 통해 서로 한 쌍인지 검사를 한다
ex) 데이터 전송, 원격제어 → GITHUB에서 푸시할 때 SSH를 활용해 파일을 전송, AWS의 인스턴스에 접속해서 원격제어
80 : www 기반 프로토콜
** www : 인터넷을 통한 상호 연결된 웹페이지 공간
443 : HTTP over SSL(암호화된 전송)
** HTTP : 텍스트를 교환, 전송을 위한 통신 규약
ex) request, response 를 통한 데이터의 교환 및 response의 성공 실패 여부에 대한 응답 코드가 header에 함께 포함되어 전송된다
** SSL : 웹서버와 웹브라우저간의 보안을 위해 만들어졌으며 주로 일반 인터넷 이용자들이 사용하고, 공개키/ 개인키 대칭키 기반으로 사용된다
ex) 공개키 → 암호화에 사용 / 개인키 → 복호화에 사용
** HTTPS : HTTP + SSL이 합쳐진 쉽게 말하면 보안이 강화된 HTTP다
3306 : MySQL
8080 : 프로젝트 서버
유동적인 IP를 잡기 위해 탄력적 IP 설정
Putty 연결 - Ubuntu연결 - Install ( Git, MySQL )
MySQL Database 생성, User 생성
database : example
user : kschoi
Travis 가입 - GitHub연동 - Travis설정 - AWS 연동 - Deploy
Travis 가입 후 GitHub와 연동
Project 최상위 폴더에 .travis.yml 생성
### 사용 언어
language: java
### jdk 버전
jdk:
- openjdk11
### github 브런치 어디에 push 되면 빌드, 테스트 할 것인지
branches:
only:
- master
### install 하기 전 실행
before_install:
- openssl {travis 암호화 복호화 값 추가}
- chmod +x gradlew
### 저장될 캐시 위치 설정
cache:
directories:
- "$HOME/.m2/repository"
- "$HOME/.gradle"
### 스크립트 실행하기 전 명령
before_script:
- chmod +x gradlew
### 실행할 스크립트 언어
script:
- "./gradlew clean build"
### 빌드, 테스트시 전송할 메일
notifications:
email:
recipients:
- {본인 이메일 주소}
### aws code deploy 하기전에 실행되는 명령
### 하나의 파일, 하나의 폴더 각각으로 deploy 한다면 오래 걸리기 때문에 zip으로 묶는다
before_deploy:
- zip -r springboot-webservice *
- mkdir -p deploy
- mv springboot-webservice.zip deploy/springboot-webservice.zip
### deploy를 한다
### aws에는 s3 - 버킷 - codedeploy 를 생성해야 한다.
### AWS_ACCESS_KEY와 AWS_SECRET_KEY는 s3를 생성할 때 한번만 주는데
### 그것을 잘 저장해두고 travis의 환경변수에 등록해주면 되겠다
deploy:
- provider: s3
access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
bucket: springboot-deploy-bucket # aws에서 생성한 버킷
region: ap-northeast-2 ### 서울 위치
skip_cleanup: true
acl: public_read
wait-until-deployed: true
local_dir: deploy # before_deploy에서 생성한 디렉토리
on:
repo: kschoi93/spring-jpa-mysql-aws ### Github 주소
branch: master ### Git branch
- provider: codedeploy
access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
bucket: springboot-deploy-bucket # S3 버킷
key: springboot-webservice.zip # S3 버킷에 저장된 springboot-webservice.zip 파일을 EC2로 배포
bundle_type: zip
application: springboot-webservice # 웹 콘솔에서 등록한 CodeDeploy 어플리케이션
deployment_group: springboot-webservice-group # 웹 콘솔에서 등록한 CodeDeploy 배포 그룹
region: ap-northeast-2 ### 서울 위치
wait-until-deployed: true
on:
repo: kschoi93/spring-jpa-mysql-aws ### Github 주소
branch: master ### Git branch
- push error 1
권한이 없다고 함
→ before_script : - chmod +x gradlew 권한 추가
$ ./gradlew assemble
195/home/travis/.travis/functions: line 351: ./gradlew: Permission denied
196
197The command "eval ./gradlew assemble " failed. Retrying, 2 of 3.
- push error 2
jdk version 까지 정확하게 작성하기 위해 11.0.11 으로 적었더니 error 발생
→ jdk : openjdk11 로 변경
The command "~/bin/install-jdk.sh --target "/home/travis/openjdk11.0.11" --workspace "/home/travis/.cache/install-jdk" --feature "11.0.11" --license "GPL" --cacerts" failed and exited with 1 during .
- push error 3
지속적인 test FAILED 발생, 문제 발생의 원인은 application.yml의 보안성 있는 데이터로 인해 .gitignore에 제외 파일로 설정해서 GitHub에 해당 파일이 push되지 않아 발생
이로 인해 jasypt 암호화, 복호화 진행
Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///home/travis/build/kschoi93/spring-jpa-mysql-aws/build/reports/tests/test/index.html
- jasypt 암복호화
gradle dependencies에 추가
→ implementation 'org.jasypt:jasypt:1.9.3'
configuration 설정
→ src/main/hello/hellospring에 jasyptConfig 작성
@Configuration
public class JasyptConfig {
// ${jasypt.encryptor.password} : jasypt 암복호화 password 키는 yml에 jasypt.encryptor.password
// 이렇게 작성되는데 이 값을 가져오는 변수 선언이라 생각하면 된다
@Value("${jasypt.encryptor.password}")
private String encryptKey;
@Bean("jasyptStringEncryptor")
public StringEncryptor stringEncryptor() {
PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
SimpleStringPBEConfig config = new SimpleStringPBEConfig();
config.setPassword(encryptKey);
config.setAlgorithm("PBEWithMD5AndDES");
config.setKeyObtentionIterations("1000");
config.setPoolSize("1");
config.setProviderName("SunJCE");
config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
config.setStringOutputType("base64");
encryptor.setConfig(config);
return encryptor;
}
}
1차 시도
→ yml에 작성
jasypt:
encryptor:
bean: jasyptStringEncryptor #bean에서 작성한 이름 입력
password: {암호화 복호화에 사용될 비밀번호}
2차 시도
→ encryptKey에 사용될 비밀번호가 application.yml에 작성되어 있을 경우
jasypt를 사용해 암호화 한 것이 유명무실 하기 때문에 yml에 설정을 작성하지 않고
@value("${jasypt.encryptor.password}") 에 데이터를 받을 때
Local 에서는 VM Options를 통해 암호화
AWS 에서는 환경변수로 복호화 하고자 했음
** VM Options : VSCode 기준 launch.json에 추가
→ "vmArgs": [ "-Djasypt.encryptor.password={암호화에 사용할 비밀번호}",]
아래는 암호화 값을 얻기 위해 사전에 java 실행 코드를 작성해서 사용
실행해서 암호화된 값을 얻었으면 application.yml에
db 주소, username, password를 ENC( !@#ASD!@#) 로 넣어주면 되겠다.
@Override
public void run(String... args) throws Exception {
StandardPBEStringEncryptor pbeEnc = new StandardPBEStringEncryptor();
pbeEnc.setAlgorithm("PBEWithMD5AndDES");
pbeEnc.setPassword("{암호화 / 복호화할 키}"); // 2번 설정의 암호화 키를 입력
String enc = pbeEnc.encrypt("{암호화할 내용}"); // 암호화 할 내용
System.out.println("enc = " + enc); // 암호화 한 내용을 출력
String enc2 = pbeEnc.encrypt("{암호화할 내용}");
System.out.println("enc = " + enc2);
String enc3 = pbeEnc.encrypt("{암호화할 내용}");
System.out.println("enc = " + enc3);
// 테스트용 복호화
String des = pbeEnc.decrypt(enc);
System.out.println("des = " + des);
String des2 = pbeEnc.decrypt(enc2);
System.out.println("des = " + des2);
String des3 = pbeEnc.decrypt(enc3);
System.out.println("des = " + des3);
}
3차 시도
→ 암호화까지 하고 application.yml을 .gitignore에서 제외해서 올렸지만 Travis 빌드 테스트시 복호화 하기 위해 필요한 암 복호화 password를 전달해 줄 수가 없어 다른 방법을 강구
** 이 방법은 CI/CD 환경이 아닌 GitHub로만 주고 받는 환경에서 사용 가능하다 판단됨
4차 시도
→ Travis 자체에 있는 암 복호화 기능을 사용
조건1 : .travis.yml과 같은 위치에 있어야 복호화 가능
조건2 : Travis 암호화를 사용하기 위해서는 Ruby를 설치해야 한다. shell에서 불가능.
Ruby를 설치해 로그인 하려 하였으나 로그인 불가능.
GitHub Token을 통한 로그인을 위해 GitHub Token 생성 및 Travis에 등록, 연동 및 로그인.
암호화 & 암호화된 파일 위치 이동 & Git Push 했으나 여전히 오류 발생
확인 결과 Travis는 org와 com 두 종류로 로그인, 암호화가 나뉜다
org는 사용되지 않고 com이 최신으로 유지되는 곳인데 로그인 할 때
—pro를 붙여줘야지 com으로 로그인, 암호화가 가능하다
travis login --pro --github-token {토큰}
application.yml이 있는 위치로 cd로 이동해서
travis encrypt-file --pro application.yml --add
하면 만들어 둔 .travis.yml 에 add 하겠냐고 나오는데
y 누르면 된다
그럼 application.yml.enc가 생성된다
mv application.yml.enc /home/ubuntu/spring/
명령어로 .travis.yml이 포함되어 있는 폴더 위치로 이동시킨다
* 같은 위치에 있어야지 application.yml.enc 복호화가 가능하다
조건3 : Window에서 암호화를 할 경우 Error 발생 (Ruby를 설치해서 사용해도 Error발생),
무조건 Linux 상에서만 암호화 사용 가능 Linux에서 Travis Install을 통해 암호화 진행
다시 테스트 해봤으나 계속 오류 발생
확인 결과 계속된 encypt-file ..... —add로 .travis.yml의 env list에 계속 쌓이게 되어
key, iv값이 6개가 넘게 쌓여 새로 생긴 키 값들이 무효화 되었다.
.travis.yml에 보이는 환경변수 값과 env list에 실제로 보이는 환경변수 값은 다를 수 있으니 유념하자
즉, .travis.yml에서 눈으로 보이는 환경변수 값을 삭제해도 env list에는 남아있다.
env 리스트를 확인하고 key와 iv를 모두 아래와 같은 방식으로 삭제하고
travis env list
travis env unset encrypted_1116e8e6e492_key
travis env unset encrypted_1116e8e6e492_iv
다시 암호화를 진행해서 마지막으로 추가 완료
Deploy 순서
→ Travis에서 AWS 접근이 가능하도록 IAM으로 권한 설정
→ Travis에서 zip으로 압축한 파일을 S3로 파일을 보낸다
→ 해당 파일을 AWS에 CodeDeploy 즉 배포한다
** CodeDeploy시 설정은 appspec.yml으로 폴더 최상단에 위치하도록 한다.
** 해당 설명은 아래에 있다.
AWS IAM → 권한 인가 설정 및 생성
AWS S3 → AWS 데이터 컨테이너, 즉 데이터 저장될 버킷 생성
AWS CodeDeploy → 푸시가 되면 S3에 있는 파일을 AWS상에 배포한다
배포 위치는? 아래 appspec.yml에 적어 놓은 대로 실행
appspec.yml → codedeploy 배포시 설정대로 실행
appspec.yml도 최상위 폴더에 저장
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app/travis/build/ ### 저장될 위치 설정
hooks:
AfterInstall: #배포가 끝나면 아래 명령어를 실행
- location: execute-deploy.sh
timeout: 180
execute-deploy.sh → appspec.yml에 적혀있는 사항인 exceute-deploy.sh 실행해서 deploy.sh를 실행하기 위해 작성
#!/bin/bash
/home/ec2-user/app/travis/deploy.sh > /dev/null 2> /dev/null < /dev/null &
위에 적혀있는 경로 + 아래와 같이 build, jar 폴더를 미리 생성해 두자
deploy.sh가 실행되면 실행될 명령어
#!/bin/bash
REPOSITORY=/home/ec2-user/app/travis
echo "> 현재 구동중인 애플리케이션 pid 확인"
CURRENT_PID=$(pgrep -f hello-spring)
echo "$CURRENT_PID"
if [ -z $CURRENT_PID ]; then
echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
echo "> kill -15 $CURRENT_PID"
kill -15 $CURRENT_PID
sleep 5
fi
echo "> application.yml 파일 resources폴더로 이동"
mv $REPOSITORY/build/application.yml $REPOSITORY/build/src/main/resources/
echo "> rebuild"
cd /home/ec2-user/app/travis/build
./gradlew clean build
cd ..
echo "> 새 어플리케이션 배포"
echo "> Build 파일 복사"
cp $REPOSITORY/build/build/libs/*.jar $REPOSITORY/jar/
JAR_NAME=$(ls $REPOSITORY/jar/ |grep 'hello-spring' | tail -n 1)
echo "> JAR Name: $JAR_NAME"
echo "> $JAR_NAME에 실행권한 추가"
chmod 777 $JAR_NAME
nohup java -jar $REPOSITORY/jar/$JAR_NAME &
이로써 AWS → CI → CD의 끝이다
google을 통해 알아보면서 했기 때문에 예제들이 있었지만 꽤 오래 걸렸다.
이유로는 아래와 같다.
우선 Linux에 친숙하지 않았었고 예제들을 보면 Travis와 AWS 연결 시 암 복호화를 사용하지 않았는데, 나는 처음부터 보안을 생각하고 적용하려 했었다. 과연 내가 보안을 신경 쓰지 않으면 추후에 언제 적용하겠나? 라는 생각이 컸던 것 같다.
이 부분 하나 추가하는 것이 그리 어렵나? 라고 생각 할 수도 있겠지만 CI/CD에 보안을 추가하는 예제들이 없어 하나하나 적용해보며 테스트 해봤기에 꽤 쉽지 않은 여정이었다.
이를 적용해보며 Linux 시스템과 Travis, AWS, 암 복호화에 대해 어느 정도 알아가게 되는 경험이었다.
추가적으로, yml이 변경될 경우 enc 즉 암호화를 다시 진행해줘야 하는 단점이 있다!
JPA CRUD (Board)구현
application.yml
spring:
### request 요청 할 때, post와 get을 제외한 put, delete 사용하기 위한 설정
mvc:
hiddenmethod:
filter:
enabled: true
### database 설정
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://{ip주소}:{포트번호}/{데이터베이스명}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: {데이터베이스 아이디}
password: {데이터베이스 비밀번호}
### jpa 설정
jpa:
### 서버 시작 지점에 ddl문을 생성하여 db에 적용여부
generate-ddl: true
### 모든 sql문을 콘솔로 출력
show-sql: true
### database 선택
database: mysql
### hibernate와 연결할 db
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate:
### ddl문의 사용 none, create, create-drop, update, validate
ddl-auto: none
### sql문을 좀 더 정돈되게 나오게 한다
format_sql: true
### 디버깅이 용이하도록 sql문 이외의 추가적인 정보 출력
use_sql_comment: true
Controller
@Controller
@AllArgsConstructor
public class BoardController {
private BoardService boardService;
// 글쓰기 페이지 이동
@GetMapping("/board")
public String boardWrite() {
return "board/write";
}
// 글수정 페이지 이동
@GetMapping("/board/edit")
public String boardEdit(@RequestParam("no") long no, Model model) {
model.addAttribute("editList", boardService.boardSelect(no).get());
return "board/edit";
}
// 글저장
@PostMapping("/board")
public String boardWrite(HttpSession session, BoardDto dto) {
dto.setAuthor((String) session.getAttribute("logName"));
Long result = boardService.save(dto);
if (result != 0) {
return "redirect:/";
} else {
return "board/failed";
}
}
// 글조회
@GetMapping("/board/{no}")
public String boardView(@PathVariable("no") long no, Model model) {
model.addAttribute("viewList", boardService.boardSelect(no).get());
return "board/view";
}
// 글삭제
@DeleteMapping("/board/{no}")
public void boardDelete(@PathVariable("no") long no) {
boardService.boardDelete(no);
}
// 글수정
@PutMapping("/board")
public String boardUpdate(BoardDto dto) {
long result = boardService.boardUpdate(dto);
if (result != 0) {
return "redirect:/";
} else {
return "board/failed";
}
}
}
BoardEntity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long no;
@Column(length = 500, nullable = false)
private String title;
@Column(length = 1000, nullable = false)
private String content;
@Column(length = 50, nullable = false)
private String author;
@Temporal(TemporalType.TIMESTAMP)
@CreationTimestamp
private Date writedate;
private int views;
@Builder
public Board(long no, String title, String content, String author, Date writedate, int views) {
this.no = no;
this.title = title;
this.content = content;
this.author = author;
this.writedate = writedate;
this.views = views;
}
}
BoardRepository
public interface BoardRepository extends JpaRepository<Board, Long> {
Page<Board> findAll(Pageable pageable);
Optional<Board> findByNo(long no);
void deleteByNo(long no);
}
BoardDto
@NoArgsConstructor
@Getter
@Setter
public class BoardDto {
private long no;
private String title;
private String content;
private String author;
private Date writedate;
private int views;
// DTO를 Entity로 반환한다
public Board toEntity() {
return Board.builder().no(no).title(title).content(content).author(author).writedate(writedate).views(views)
.build();
}
@Builder
public BoardDto(Board board) {
this.no = board.getNo();
this.title = board.getTitle();
this.content = board.getContent();
this.author = board.getAuthor();
this.writedate = board.getWritedate();
this.views = board.getViews();
}
}
BoardService
@AllArgsConstructor
@Transactional
@Service
public class BoardService {
private BoardRepository boardRepository;
// no 기준으로 내림차순 정렬
public Page<Board> allSelect(BoardDto dto) {
// PageRequest.of(page, size, sort)
// page = 0부터 시작한다
return boardRepository.findAll(PageRequest.of(0, 10, Direction.DESC, "no"));
}
// 글 작성
public Long save(BoardDto dto) {
return boardRepository.save(dto.toEntity()).getNo();
}
// 글 조회
public Optional<Board> boardSelect(long no) {
return boardRepository.findByNo(no);
}
// 글 삭제
public void boardDelete(long no) {
boardRepository.deleteByNo(no);
}
// 글 수정
public long boardUpdate(BoardDto dto) {
Optional<Board> optionalBoard = boardRepository.findByNo(dto.getNo());
BoardDto board = new BoardDto(optionalBoard.get());
board.setTitle(dto.getTitle());
board.setContent(dto.getContent());
return boardRepository.save(board.toEntity()).getNo();
}
}
BoardServiceTest
@RunWith(SpringRunner.class)
@Transactional
@SpringBootTest
public class BoardServiceTest {
@Autowired
private BoardRepository boardRepository;
@Test
void 게시글_작성_테스트() {
// given
BoardDto dto = new BoardDto(
Board.builder().title("testcase").content("textcaseContent").author("test").build());
// when
Long result = boardRepository.save(dto.toEntity()).getNo();
Optional<Board> board = boardRepository.findByNo(result);
// then
assertThat(board.get().getNo(), is(result));
}
@Test
void 게시글_조회_테스트() {
// given
int no = 2;
// when
Optional<Board> board = boardRepository.findByNo(no);
String title = board.get().getTitle();
// then
assertThat(title, is("test1"));
}
@Test
void 게시글_삭제_테스트() {
// given
int no = 1;
// when
boardRepository.deleteByNo(no);
Optional<Board> board = boardRepository.findByNo(no);
// then
assertThat(board.isPresent(), is(false));
}
@Test
void 업데이트_테스트() {
// given
int no = 10;
String title = "update테스트";
String content = "update테스트";
// when
BoardDto dto = new BoardDto(boardRepository.findByNo(no).get());
dto.setTitle(title);
dto.setContent(content);
Board board = boardRepository.save(dto.toEntity());
// then
assertThat(board.getTitle(), is(title));
assertThat(board.getTitle(), is(content));
}
}
JPA를 적용하여 간단한 CRUD + Test 를 구현해 보았으며
Controller, Entity, Repository, DTO, Service, Test 로 구성되어 있다.
REST API의 조건과 동일하게 하기 위해서 get, post, put, delete 로 담아 봤다.
큰 도움이 된 사이트
AWS : https://jojoldu.tistory.com/259?category=635883